mirror of
https://github.com/openai/codex.git
synced 2026-10-30 19:18:49 +00:00
[codex] expose sandbox ingress to host
This commit is contained in:
@@ -418,6 +418,7 @@ async fn run_command_under_sandbox(
|
||||
sandbox_policy_cwd.as_path(),
|
||||
use_legacy_landlock,
|
||||
allow_network_for_proxy(enforce_managed_network),
|
||||
/*ingress*/ None,
|
||||
);
|
||||
spawn_debug_sandbox_child(
|
||||
codex_linux_sandbox_exe,
|
||||
|
||||
@@ -392,6 +392,7 @@ pub fn build_exec_request(
|
||||
enforce_managed_network,
|
||||
environment_id: network_environment_id.as_deref(),
|
||||
network: network.as_ref(),
|
||||
ingress: None,
|
||||
sandbox_policy_cwd: &sandbox_policy_cwd_uri,
|
||||
codex_linux_sandbox_exe: codex_linux_sandbox_exe.as_deref(),
|
||||
use_legacy_landlock,
|
||||
@@ -444,6 +445,7 @@ pub(crate) async fn execute_exec_request(
|
||||
cwd,
|
||||
env,
|
||||
exec_server_env_config: _,
|
||||
ingress,
|
||||
network,
|
||||
expiration,
|
||||
capture_policy,
|
||||
@@ -462,6 +464,11 @@ pub(crate) async fn execute_exec_request(
|
||||
exec_server_enforce_managed_network: _,
|
||||
exec_server_managed_network: _,
|
||||
} = exec_request;
|
||||
if ingress.is_some() {
|
||||
return Err(CodexErr::UnsupportedOperation(
|
||||
"ingress requires unified exec".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// TODO(anp): Keep PathUri through the local process launch boundary.
|
||||
let cwd = cwd
|
||||
|
||||
@@ -41,6 +41,7 @@ where
|
||||
sandbox_policy_cwd,
|
||||
use_legacy_landlock,
|
||||
allow_network_for_proxy(/*enforce_managed_network*/ false),
|
||||
/*ingress*/ None,
|
||||
);
|
||||
let codex_linux_sandbox_exe = codex_linux_sandbox_exe.as_ref();
|
||||
// Preserve the helper alias when we already have it; otherwise force argv0
|
||||
|
||||
@@ -48,6 +48,7 @@ pub struct ExecRequest {
|
||||
pub cwd: PathUri,
|
||||
pub env: HashMap<String, String>,
|
||||
pub(crate) exec_server_env_config: Option<ExecServerEnvConfig>,
|
||||
pub ingress: Option<u16>,
|
||||
pub network: Option<NetworkProxy>,
|
||||
pub network_environment_id: Option<String>,
|
||||
pub expiration: ExecExpiration,
|
||||
@@ -93,6 +94,7 @@ impl ExecRequest {
|
||||
cwd,
|
||||
env,
|
||||
exec_server_env_config: None,
|
||||
ingress: None,
|
||||
network,
|
||||
network_environment_id,
|
||||
expiration,
|
||||
@@ -123,6 +125,7 @@ impl ExecRequest {
|
||||
cwd,
|
||||
sandbox_policy_cwd: windows_sandbox_policy_cwd,
|
||||
mut env,
|
||||
ingress,
|
||||
network,
|
||||
network_environment_id,
|
||||
sandbox,
|
||||
@@ -152,6 +155,7 @@ impl ExecRequest {
|
||||
cwd,
|
||||
env,
|
||||
exec_server_env_config: None,
|
||||
ingress,
|
||||
network,
|
||||
network_environment_id,
|
||||
expiration,
|
||||
|
||||
@@ -203,6 +203,7 @@ pub(crate) async fn execute_user_shell_command(
|
||||
cwd: cwd.clone().into(),
|
||||
env: exec_env_map,
|
||||
exec_server_env_config: None,
|
||||
ingress: None,
|
||||
// `/shell` is the explicit full-access escape hatch, so it must not
|
||||
// inherit a managed proxy from the surrounding session or turn.
|
||||
network: None,
|
||||
|
||||
@@ -55,6 +55,13 @@ pub(crate) fn create_exec_command_tool_with_environment_id(
|
||||
)),
|
||||
),
|
||||
]);
|
||||
#[cfg(unix)]
|
||||
properties.insert(
|
||||
"ingress".to_string(),
|
||||
JsonSchema::integer(Some(
|
||||
"Expose a TCP port from the Linux sandbox through the parent host.".to_string(),
|
||||
)),
|
||||
);
|
||||
if include_shell_parameter {
|
||||
properties.insert(
|
||||
"shell".to_string(),
|
||||
|
||||
@@ -74,6 +74,13 @@ fn exec_command_tool_matches_expected_spec() {
|
||||
)),
|
||||
),
|
||||
]);
|
||||
#[cfg(unix)]
|
||||
properties.insert(
|
||||
"ingress".to_string(),
|
||||
JsonSchema::integer(Some(
|
||||
"Expose a TCP port from the Linux sandbox through the parent host.".to_string(),
|
||||
)),
|
||||
);
|
||||
properties.extend(create_approval_parameters(
|
||||
/*exec_permission_approvals_enabled*/ false,
|
||||
));
|
||||
|
||||
@@ -33,6 +33,8 @@ pub(crate) struct ExecCommandArgs {
|
||||
login: Option<bool>,
|
||||
#[serde(default = "default_tty")]
|
||||
tty: bool,
|
||||
#[serde(default)]
|
||||
ingress: Option<u16>,
|
||||
#[serde(default = "default_exec_yield_time_ms")]
|
||||
yield_time_ms: u64,
|
||||
#[serde(default)]
|
||||
|
||||
@@ -242,6 +242,7 @@ impl ExecCommandHandler {
|
||||
|
||||
let ExecCommandArgs {
|
||||
tty,
|
||||
ingress,
|
||||
yield_time_ms,
|
||||
max_output_tokens,
|
||||
sandbox_permissions,
|
||||
@@ -355,6 +356,7 @@ impl ExecCommandHandler {
|
||||
shell_mode,
|
||||
network: context.turn.network.clone(),
|
||||
tty,
|
||||
ingress,
|
||||
sandbox_permissions: effective_additional_permissions.sandbox_permissions,
|
||||
additional_permissions: normalized_additional_permissions,
|
||||
additional_permissions_preapproved: effective_additional_permissions
|
||||
|
||||
@@ -128,6 +128,7 @@ async fn explicit_escalation_prepares_exec_without_managed_network() -> anyhow::
|
||||
Some(&proxy),
|
||||
SandboxPermissions::RequireEscalated,
|
||||
),
|
||||
/*ingress*/ None,
|
||||
/*environment_id*/ None,
|
||||
)
|
||||
.expect("prepare exec request");
|
||||
|
||||
@@ -326,6 +326,7 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
|
||||
command,
|
||||
options,
|
||||
managed_network,
|
||||
/*ingress*/ None,
|
||||
Some(&req.turn_environment.environment_id),
|
||||
)
|
||||
.map_err(ToolError::Codex)?;
|
||||
|
||||
@@ -144,6 +144,7 @@ pub(super) async fn try_run_zsh_fork(
|
||||
command,
|
||||
options,
|
||||
managed_network_for_sandbox_permissions(req.network.as_ref(), req.sandbox_permissions),
|
||||
/*ingress*/ None,
|
||||
Some(&req.turn_environment.environment_id),
|
||||
)
|
||||
.map_err(ToolError::Codex)?;
|
||||
@@ -152,6 +153,7 @@ pub(super) async fn try_run_zsh_fork(
|
||||
cwd: sandbox_cwd,
|
||||
env: sandbox_env,
|
||||
exec_server_env_config: _,
|
||||
ingress: _,
|
||||
network: sandbox_network,
|
||||
network_environment_id,
|
||||
expiration: _sandbox_expiration,
|
||||
@@ -887,6 +889,7 @@ impl CoreShellCommandExecutor {
|
||||
cwd: self.cwd.clone().into(),
|
||||
env: exec_env,
|
||||
exec_server_env_config: None,
|
||||
ingress: None,
|
||||
network: self.network.clone(),
|
||||
network_environment_id: self.network_environment_id.clone(),
|
||||
expiration: ExecExpiration::Cancellation(cancel_rx),
|
||||
@@ -1026,6 +1029,7 @@ impl CoreShellCommandExecutor {
|
||||
enforce_managed_network: self.network.is_some(),
|
||||
environment_id: self.network_environment_id.as_deref(),
|
||||
network: self.network.as_ref(),
|
||||
ingress: None,
|
||||
sandbox_policy_cwd: &sandbox_policy_cwd,
|
||||
codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.as_deref(),
|
||||
use_legacy_landlock: self.use_legacy_landlock,
|
||||
|
||||
@@ -74,6 +74,7 @@ pub struct UnifiedExecRequest {
|
||||
pub explicit_env_overrides: HashMap<String, String>,
|
||||
pub network: Option<NetworkProxy>,
|
||||
pub tty: bool,
|
||||
pub ingress: Option<u16>,
|
||||
pub sandbox_permissions: SandboxPermissions,
|
||||
pub additional_permissions: Option<AdditionalPermissionProfile>,
|
||||
#[cfg(unix)]
|
||||
@@ -90,6 +91,7 @@ pub struct UnifiedExecApprovalKey {
|
||||
pub command: Vec<String>,
|
||||
pub cwd: PathUri,
|
||||
pub tty: bool,
|
||||
pub ingress: Option<u16>,
|
||||
pub sandbox_permissions: SandboxPermissions,
|
||||
pub additional_permissions: Option<AdditionalPermissionProfile>,
|
||||
}
|
||||
@@ -163,6 +165,7 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
|
||||
command: canonicalize_command_for_approval(&req.command),
|
||||
cwd: req.cwd.clone(),
|
||||
tty: req.tty,
|
||||
ingress: req.ingress,
|
||||
sandbox_permissions: req.sandbox_permissions,
|
||||
additional_permissions: req.additional_permissions.clone(),
|
||||
}]
|
||||
@@ -409,6 +412,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
|
||||
command,
|
||||
options,
|
||||
managed_network,
|
||||
req.ingress,
|
||||
Some(&req.turn_environment.environment_id),
|
||||
)
|
||||
.map_err(ToolError::Codex)?;
|
||||
@@ -477,6 +481,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
|
||||
options,
|
||||
attempt,
|
||||
managed_network,
|
||||
req.ingress,
|
||||
/*environment_id*/ Some(&req.turn_environment.environment_id),
|
||||
req.exec_server_env_config.clone(),
|
||||
req.tty,
|
||||
@@ -574,6 +579,7 @@ mod tests {
|
||||
explicit_env_overrides: HashMap::new(),
|
||||
network: None,
|
||||
tty: false,
|
||||
ingress: None,
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
additional_permissions: None,
|
||||
#[cfg(unix)]
|
||||
@@ -676,6 +682,7 @@ mod tests {
|
||||
explicit_env_overrides: HashMap::new(),
|
||||
network: None,
|
||||
tty: false,
|
||||
ingress: None,
|
||||
sandbox_permissions,
|
||||
additional_permissions: None,
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -431,6 +431,7 @@ impl<'a> SandboxAttempt<'a> {
|
||||
command: SandboxCommand,
|
||||
options: ExecOptions,
|
||||
network: Option<&NetworkProxy>,
|
||||
ingress: Option<u16>,
|
||||
environment_id: Option<&str>,
|
||||
) -> Result<crate::sandboxing::ExecRequest, CodexErr> {
|
||||
let request = self
|
||||
@@ -442,6 +443,7 @@ impl<'a> SandboxAttempt<'a> {
|
||||
enforce_managed_network: self.enforce_managed_network,
|
||||
environment_id,
|
||||
network,
|
||||
ingress,
|
||||
sandbox_policy_cwd: self.sandbox_cwd,
|
||||
codex_linux_sandbox_exe: self
|
||||
.codex_linux_sandbox_exe
|
||||
@@ -463,6 +465,7 @@ impl<'a> SandboxAttempt<'a> {
|
||||
command: SandboxCommand,
|
||||
options: ExecOptions,
|
||||
network: Option<&NetworkProxy>,
|
||||
ingress: Option<u16>,
|
||||
environment_id: Option<&str>,
|
||||
) -> Result<crate::sandboxing::ExecRequest, CodexErr> {
|
||||
let managed_network = command.managed_network.clone();
|
||||
@@ -480,6 +483,7 @@ impl<'a> SandboxAttempt<'a> {
|
||||
enforce_managed_network: self.enforce_managed_network,
|
||||
environment_id,
|
||||
network,
|
||||
ingress: None,
|
||||
sandbox_policy_cwd: self.sandbox_cwd,
|
||||
codex_linux_sandbox_exe: None,
|
||||
use_legacy_landlock: self.use_legacy_landlock,
|
||||
@@ -492,6 +496,7 @@ impl<'a> SandboxAttempt<'a> {
|
||||
options,
|
||||
self.workspace_roots.to_vec(),
|
||||
);
|
||||
exec_request.ingress = ingress;
|
||||
exec_request.exec_server_managed_network = managed_network;
|
||||
if self.sandbox_requested {
|
||||
exec_request.exec_server_sandbox = Some(FileSystemSandboxContext {
|
||||
|
||||
@@ -245,7 +245,13 @@ fn exec_server_env_keeps_command_native_and_carries_sandbox_context() {
|
||||
capture_policy: crate::exec::ExecCapturePolicy::ShellTool,
|
||||
};
|
||||
let request = attempt
|
||||
.env_for_exec_server(command(), options(), /*network*/ None, Some("remote"))
|
||||
.env_for_exec_server(
|
||||
command(),
|
||||
options(),
|
||||
/*network*/ None,
|
||||
/*ingress*/ None,
|
||||
Some("remote"),
|
||||
)
|
||||
.expect("prepare remote exec request");
|
||||
|
||||
assert_eq!(
|
||||
@@ -275,9 +281,29 @@ fn exec_server_env_keeps_command_native_and_carries_sandbox_context() {
|
||||
Some(managed_network.clone())
|
||||
);
|
||||
|
||||
let ingress_request = attempt
|
||||
.env_for_exec_server(
|
||||
command(),
|
||||
options(),
|
||||
/*network*/ None,
|
||||
/*ingress*/ Some(4173),
|
||||
Some("remote"),
|
||||
)
|
||||
.expect("prepare ingress remote exec request");
|
||||
|
||||
assert_eq!(ingress_request.ingress, Some(4173));
|
||||
assert_eq!(ingress_request.sandbox, SandboxType::None);
|
||||
assert!(ingress_request.exec_server_sandbox.is_some());
|
||||
|
||||
attempt.sandbox_requested = false;
|
||||
let request = attempt
|
||||
.env_for_exec_server(command(), options(), /*network*/ None, Some("remote"))
|
||||
.env_for_exec_server(
|
||||
command(),
|
||||
options(),
|
||||
/*network*/ None,
|
||||
/*ingress*/ None,
|
||||
Some("remote"),
|
||||
)
|
||||
.expect("prepare unsandboxed remote exec request");
|
||||
|
||||
assert_eq!(request.exec_server_sandbox, None);
|
||||
|
||||
@@ -102,6 +102,7 @@ pub(crate) struct ExecCommandRequest {
|
||||
pub shell_mode: UnifiedExecShellMode,
|
||||
pub network: Option<NetworkProxy>,
|
||||
pub tty: bool,
|
||||
pub ingress: Option<u16>,
|
||||
pub sandbox_permissions: SandboxPermissions,
|
||||
pub additional_permissions: Option<AdditionalPermissionProfile>,
|
||||
pub additional_permissions_preapproved: bool,
|
||||
|
||||
@@ -20,6 +20,8 @@ use codex_exec_server::StartedExecProcess;
|
||||
use codex_exec_server::WriteResponse;
|
||||
use codex_exec_server::WriteStatus;
|
||||
use codex_sandboxing::SandboxType;
|
||||
#[cfg(unix)]
|
||||
use codex_sandboxing::ingress::INGRESS_LISTENER_FD_ENV_VAR;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
use codex_utils_output_truncation::approx_token_count;
|
||||
@@ -28,6 +30,10 @@ use core_test_support::skip_if_sandbox;
|
||||
use core_test_support::test_codex::test_env as remote_test_env;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashMap;
|
||||
#[cfg(unix)]
|
||||
use std::net::Ipv4Addr;
|
||||
#[cfg(unix)]
|
||||
use std::net::TcpListener;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Notify;
|
||||
@@ -201,6 +207,78 @@ async fn exec_command_with_tty(
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn local_ingress_passes_inherited_listener_to_child() -> anyhow::Result<()> {
|
||||
skip_if_sandbox!(Ok(()));
|
||||
|
||||
let (session, turn) = test_session_and_turn().await;
|
||||
let manager = &session.services.unified_exec_manager;
|
||||
let process_id = manager.allocate_process_id().await;
|
||||
#[allow(deprecated)]
|
||||
let cwd = turn.cwd.clone();
|
||||
let command = vec![
|
||||
"bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
r#"test -n "$CODEX_INGRESS_LISTENER_FD" && eval "true <&${CODEX_INGRESS_LISTENER_FD}" && echo ingress-ready"#
|
||||
.to_string(),
|
||||
];
|
||||
let mut env = shell_env();
|
||||
env.remove(INGRESS_LISTENER_FD_ENV_VAR);
|
||||
let mut request = test_exec_request(turn.as_ref(), command, cwd, env);
|
||||
request.ingress = Some(unused_ingress_port());
|
||||
|
||||
let process = manager
|
||||
.open_session_with_prepared_exec_env(
|
||||
process_id,
|
||||
&request,
|
||||
/*tty*/ false,
|
||||
Box::new(NoopSpawnLifecycle),
|
||||
turn.environments
|
||||
.primary()
|
||||
.expect("turn environment")
|
||||
.environment
|
||||
.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
let OutputHandles {
|
||||
output_buffer,
|
||||
output_notify,
|
||||
output_closed,
|
||||
output_closed_notify,
|
||||
cancellation_token,
|
||||
} = process.output_handles();
|
||||
let output = UnifiedExecProcessManager::collect_output_until_deadline(
|
||||
&output_buffer,
|
||||
&output_notify,
|
||||
&output_closed,
|
||||
&output_closed_notify,
|
||||
&cancellation_token,
|
||||
Some(session.subscribe_out_of_band_elicitation_pause_state()),
|
||||
Instant::now() + Duration::from_secs(2),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(process.exit_code(), Some(0));
|
||||
assert!(
|
||||
String::from_utf8_lossy(&output).contains("ingress-ready"),
|
||||
"expected ingress child output, got: {}",
|
||||
String::from_utf8_lossy(&output),
|
||||
);
|
||||
manager.release_process_id(process_id).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn unused_ingress_port() -> u16 {
|
||||
TcpListener::bind((Ipv4Addr::UNSPECIFIED, 0))
|
||||
.expect("bind ephemeral ingress listener")
|
||||
.local_addr()
|
||||
.expect("read ephemeral ingress listener address")
|
||||
.port()
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TestSpawnLifecycle {
|
||||
inherited_fds: Vec<i32>,
|
||||
|
||||
@@ -62,6 +62,10 @@ use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::error::SandboxErr;
|
||||
use codex_protocol::protocol::ExecCommandSource;
|
||||
use codex_sandboxing::SandboxCommand;
|
||||
#[cfg(unix)]
|
||||
use codex_sandboxing::ingress::IngressListener;
|
||||
#[cfg(unix)]
|
||||
use codex_sandboxing::ingress::IngressListenerError;
|
||||
use codex_tools::ToolName;
|
||||
use codex_utils_output_truncation::approx_token_count;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
@@ -188,6 +192,7 @@ fn exec_server_params_for_request(
|
||||
env,
|
||||
tty,
|
||||
pipe_stdin: false,
|
||||
ingress: request.ingress,
|
||||
arg0: request.arg0.clone(),
|
||||
sandbox: request.exec_server_sandbox.clone(),
|
||||
enforce_managed_network: request.exec_server_enforce_managed_network,
|
||||
@@ -920,6 +925,7 @@ impl UnifiedExecProcessManager {
|
||||
options: ExecOptions,
|
||||
attempt: &SandboxAttempt<'_>,
|
||||
network: Option<&NetworkProxy>,
|
||||
ingress: Option<u16>,
|
||||
environment_id: Option<&str>,
|
||||
exec_server_env_config: Option<ExecServerEnvConfig>,
|
||||
tty: bool,
|
||||
@@ -927,9 +933,9 @@ impl UnifiedExecProcessManager {
|
||||
environment: &codex_exec_server::Environment,
|
||||
) -> Result<UnifiedExecProcess, ToolError> {
|
||||
let mut request = if environment.is_remote() {
|
||||
attempt.env_for_exec_server(command, options, network, environment_id)
|
||||
attempt.env_for_exec_server(command, options, network, ingress, environment_id)
|
||||
} else {
|
||||
attempt.env_for(command, options, network, environment_id)
|
||||
attempt.env_for(command, options, network, ingress, environment_id)
|
||||
}
|
||||
.map_err(ToolError::Codex)?;
|
||||
request.exec_server_env_config = exec_server_env_config;
|
||||
@@ -960,8 +966,6 @@ impl UnifiedExecProcessManager {
|
||||
mut spawn_lifecycle: SpawnLifecycleHandle,
|
||||
environment: &codex_exec_server::Environment,
|
||||
) -> Result<UnifiedExecProcess, UnifiedExecError> {
|
||||
let inherited_fds = spawn_lifecycle.inherited_fds();
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
if request.sandbox == codex_sandboxing::SandboxType::WindowsRestrictedToken {
|
||||
// TODO(anp): Keep PathUri through the Windows sandbox launch boundary.
|
||||
@@ -1049,6 +1053,7 @@ impl UnifiedExecProcessManager {
|
||||
.await;
|
||||
}
|
||||
if environment.is_remote() {
|
||||
let inherited_fds = spawn_lifecycle.inherited_fds();
|
||||
if !inherited_fds.is_empty() {
|
||||
return Err(UnifiedExecError::create_process(
|
||||
"remote exec-server does not support inherited file descriptors".to_string(),
|
||||
@@ -1064,6 +1069,22 @@ impl UnifiedExecProcessManager {
|
||||
return UnifiedExecProcess::from_exec_server_started(started).await;
|
||||
}
|
||||
|
||||
let mut env = request.env.clone();
|
||||
let mut inherited_fds = spawn_lifecycle.inherited_fds();
|
||||
#[cfg(unix)]
|
||||
let ingress = IngressListener::prepare(request.ingress).map_err(ingress_error)?;
|
||||
#[cfg(unix)]
|
||||
if let Some(ingress) = ingress.as_ref() {
|
||||
ingress.add_to_child_env(&mut env);
|
||||
inherited_fds.push(ingress.inherited_fd());
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
if request.ingress.is_some() {
|
||||
return Err(UnifiedExecError::create_process(
|
||||
"ingress is only supported on Unix hosts".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// TODO(anp): Keep PathUri through the local PTY/process launch boundary.
|
||||
let native_cwd = request
|
||||
.cwd
|
||||
@@ -1081,7 +1102,7 @@ impl UnifiedExecProcessManager {
|
||||
program,
|
||||
args,
|
||||
native_cwd.as_path(),
|
||||
&request.env,
|
||||
&env,
|
||||
&request.arg0,
|
||||
codex_utils_pty::TerminalSize::default(),
|
||||
&inherited_fds,
|
||||
@@ -1092,7 +1113,7 @@ impl UnifiedExecProcessManager {
|
||||
program,
|
||||
args,
|
||||
native_cwd.as_path(),
|
||||
&request.env,
|
||||
&env,
|
||||
&request.arg0,
|
||||
&inherited_fds,
|
||||
)
|
||||
@@ -1100,6 +1121,8 @@ impl UnifiedExecProcessManager {
|
||||
};
|
||||
let spawned =
|
||||
spawn_result.map_err(|err| UnifiedExecError::create_process(err.to_string()))?;
|
||||
#[cfg(unix)]
|
||||
drop(ingress);
|
||||
spawn_lifecycle.after_spawn();
|
||||
UnifiedExecProcess::from_spawned(spawned, request.sandbox, spawn_lifecycle).await
|
||||
}
|
||||
@@ -1166,6 +1189,7 @@ impl UnifiedExecProcessManager {
|
||||
.clone(),
|
||||
network: request.network.clone(),
|
||||
tty: request.tty,
|
||||
ingress: request.ingress,
|
||||
sandbox_permissions: request.sandbox_permissions,
|
||||
additional_permissions: request.additional_permissions.clone(),
|
||||
#[cfg(unix)]
|
||||
@@ -1448,6 +1472,21 @@ impl UnifiedExecProcessManager {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn ingress_error(error: IngressListenerError) -> UnifiedExecError {
|
||||
match error {
|
||||
IngressListenerError::InvalidPort => {
|
||||
UnifiedExecError::create_process("ingress port must be between 1 and 65535".to_string())
|
||||
}
|
||||
IngressListenerError::PortInUse(port) => UnifiedExecError::create_process(format!(
|
||||
"ingress port {port} is already in use by another process"
|
||||
)),
|
||||
IngressListenerError::Io(error) => {
|
||||
UnifiedExecError::create_process(format!("failed to prepare ingress listener: {error}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ProcessStatus {
|
||||
Alive {
|
||||
exit_code: Option<i32>,
|
||||
|
||||
@@ -157,6 +157,7 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() {
|
||||
),
|
||||
]),
|
||||
}),
|
||||
ingress: None,
|
||||
network: None,
|
||||
network_environment_id: None,
|
||||
expiration: crate::exec::ExecExpiration::DefaultTimeout,
|
||||
@@ -287,6 +288,7 @@ async fn failed_initial_end_for_unstored_process_uses_fallback_output() {
|
||||
shell_mode: codex_tools::UnifiedExecShellMode::Direct,
|
||||
network: None,
|
||||
tty: true,
|
||||
ingress: None,
|
||||
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
|
||||
additional_permissions: None,
|
||||
additional_permissions_preapproved: false,
|
||||
|
||||
@@ -129,6 +129,9 @@ pub struct ExecParams {
|
||||
/// Keep non-tty stdin writable through `process/write`.
|
||||
#[serde(default)]
|
||||
pub pipe_stdin: bool,
|
||||
/// Expose a TCP port from the Linux sandbox through the executor host.
|
||||
#[serde(default)]
|
||||
pub ingress: Option<u16>,
|
||||
/// Optional process-visible argv0 override. Values such as `codex-linux-sandbox` are command
|
||||
/// names rather than paths, so this is not a [`PathUri`].
|
||||
pub arg0: Option<String>,
|
||||
@@ -591,6 +594,7 @@ mod tests {
|
||||
pipe_stdin: false,
|
||||
arg0: None,
|
||||
sandbox: None,
|
||||
ingress: None,
|
||||
enforce_managed_network: true,
|
||||
managed_network: Some(ManagedNetworkSandboxContext {
|
||||
loopback_ports: vec![43123, 48081],
|
||||
|
||||
@@ -1374,6 +1374,7 @@ mod tests {
|
||||
env: HashMap::new(),
|
||||
tty: false,
|
||||
pipe_stdin: false,
|
||||
ingress: None,
|
||||
arg0: None,
|
||||
sandbox: None,
|
||||
enforce_managed_network: false,
|
||||
|
||||
@@ -1172,6 +1172,7 @@ mod tests {
|
||||
env: Default::default(),
|
||||
tty: false,
|
||||
pipe_stdin: false,
|
||||
ingress: None,
|
||||
arg0: None,
|
||||
sandbox: None,
|
||||
enforce_managed_network: false,
|
||||
@@ -1212,6 +1213,7 @@ mod tests {
|
||||
env: Default::default(),
|
||||
tty: false,
|
||||
pipe_stdin: false,
|
||||
ingress: None,
|
||||
arg0: None,
|
||||
sandbox: Some(sandbox),
|
||||
enforce_managed_network: false,
|
||||
|
||||
@@ -140,6 +140,7 @@ impl FileSystemSandboxRunner {
|
||||
enforce_managed_network: false,
|
||||
environment_id: None,
|
||||
network: None,
|
||||
ingress: None,
|
||||
sandbox_policy_cwd: &cwd.uri,
|
||||
codex_linux_sandbox_exe: self.runtime_paths.codex_linux_sandbox_exe.as_deref(),
|
||||
use_legacy_landlock: sandbox_context.use_legacy_landlock,
|
||||
|
||||
@@ -14,6 +14,10 @@ use codex_protocol::exec_output::ExecToolCallOutput;
|
||||
use codex_protocol::exec_output::StreamOutput;
|
||||
use codex_protocol::shell_environment;
|
||||
use codex_sandboxing::SandboxType;
|
||||
#[cfg(unix)]
|
||||
use codex_sandboxing::ingress::IngressListener;
|
||||
#[cfg(unix)]
|
||||
use codex_sandboxing::ingress::IngressListenerError;
|
||||
use codex_sandboxing::is_likely_sandbox_denied;
|
||||
use codex_utils_pty::ExecCommandSession;
|
||||
use codex_utils_pty::ProcessSignal as PtyProcessSignal;
|
||||
@@ -234,8 +238,30 @@ impl LocalProcess {
|
||||
params: ExecParams,
|
||||
) -> Result<(ExecResponse, watch::Sender<u64>, ExecProcessEventLog), JSONRPCErrorError> {
|
||||
let process_id = params.process_id.clone();
|
||||
let prepared =
|
||||
prepare_exec_request(¶ms, child_env(¶ms), self.runtime_paths.as_ref())?;
|
||||
let mut env = child_env(¶ms);
|
||||
#[cfg(unix)]
|
||||
let ingress = match IngressListener::prepare(params.ingress) {
|
||||
Ok(ingress) => ingress,
|
||||
Err(error) => return Err(ingress_error(error)),
|
||||
};
|
||||
#[cfg(unix)]
|
||||
let inherited_fds = ingress
|
||||
.as_ref()
|
||||
.map(|ingress| vec![ingress.inherited_fd()])
|
||||
.unwrap_or_default();
|
||||
#[cfg(unix)]
|
||||
if let Some(ingress) = ingress.as_ref() {
|
||||
ingress.add_to_child_env(&mut env);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
if params.ingress.is_some() {
|
||||
return Err(invalid_params(
|
||||
"ingress is only supported on Unix exec-server hosts".to_string(),
|
||||
));
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let inherited_fds = Vec::new();
|
||||
let prepared = prepare_exec_request(¶ms, env, self.runtime_paths.as_ref())?;
|
||||
let (program, args) = prepared
|
||||
.command
|
||||
.split_first()
|
||||
@@ -256,31 +282,34 @@ impl LocalProcess {
|
||||
}
|
||||
|
||||
let spawned_result = if params.tty {
|
||||
codex_utils_pty::spawn_pty_process(
|
||||
codex_utils_pty::pty::spawn_process_with_inherited_fds(
|
||||
program,
|
||||
args,
|
||||
prepared.cwd.as_path(),
|
||||
&prepared.env,
|
||||
&prepared.arg0,
|
||||
TerminalSize::default(),
|
||||
&inherited_fds,
|
||||
)
|
||||
.await
|
||||
} else if params.pipe_stdin {
|
||||
codex_utils_pty::spawn_pipe_process(
|
||||
codex_utils_pty::pipe::spawn_process_with_inherited_fds(
|
||||
program,
|
||||
args,
|
||||
prepared.cwd.as_path(),
|
||||
&prepared.env,
|
||||
&prepared.arg0,
|
||||
&inherited_fds,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
codex_utils_pty::spawn_pipe_process_no_stdin(
|
||||
codex_utils_pty::pipe::spawn_process_no_stdin_with_inherited_fds(
|
||||
program,
|
||||
args,
|
||||
prepared.cwd.as_path(),
|
||||
&prepared.env,
|
||||
&prepared.arg0,
|
||||
&inherited_fds,
|
||||
)
|
||||
.await
|
||||
};
|
||||
@@ -297,6 +326,8 @@ impl LocalProcess {
|
||||
return Err(internal_error(err.to_string()));
|
||||
}
|
||||
};
|
||||
#[cfg(unix)]
|
||||
drop(ingress);
|
||||
|
||||
let output_notify = Arc::new(Notify::new());
|
||||
let (wake_tx, _wake_rx) = watch::channel(0);
|
||||
@@ -584,6 +615,21 @@ fn child_env(params: &ExecParams) -> HashMap<String, String> {
|
||||
env
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn ingress_error(error: IngressListenerError) -> JSONRPCErrorError {
|
||||
match error {
|
||||
IngressListenerError::InvalidPort => {
|
||||
invalid_request("ingress port must be between 1 and 65535".to_string())
|
||||
}
|
||||
IngressListenerError::PortInUse(port) => invalid_request(format!(
|
||||
"ingress port {port} is already in use by another process"
|
||||
)),
|
||||
IngressListenerError::Io(error) => {
|
||||
internal_error(format!("failed to prepare ingress listener: {error}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn shell_environment_policy(env_policy: &ExecEnvPolicy) -> ShellEnvironmentPolicy {
|
||||
ShellEnvironmentPolicy {
|
||||
inherit: env_policy.inherit.clone(),
|
||||
@@ -995,6 +1041,7 @@ mod tests {
|
||||
env,
|
||||
tty: false,
|
||||
pipe_stdin: false,
|
||||
ingress: None,
|
||||
arg0: None,
|
||||
sandbox: None,
|
||||
enforce_managed_network: false,
|
||||
|
||||
@@ -32,6 +32,11 @@ pub(crate) fn prepare_exec_request(
|
||||
runtime_paths: Option<&ExecServerRuntimePaths>,
|
||||
) -> Result<PreparedExecRequest, JSONRPCErrorError> {
|
||||
let Some(sandbox_context) = params.sandbox.as_ref() else {
|
||||
if params.ingress.is_some() {
|
||||
return Err(invalid_params(
|
||||
"ingress requires sandbox intent on exec-server".to_string(),
|
||||
));
|
||||
}
|
||||
return Ok(PreparedExecRequest {
|
||||
command: params.argv.clone(),
|
||||
cwd: native_path(¶ms.cwd, "cwd")?,
|
||||
@@ -125,6 +130,7 @@ pub(crate) fn prepare_exec_request(
|
||||
enforce_managed_network: params.enforce_managed_network,
|
||||
environment_id: None,
|
||||
network: None,
|
||||
ingress: params.ingress,
|
||||
sandbox_policy_cwd,
|
||||
codex_linux_sandbox_exe: runtime_paths.codex_linux_sandbox_exe.as_deref(),
|
||||
use_legacy_landlock: sandbox_context.use_legacy_landlock,
|
||||
|
||||
@@ -43,6 +43,7 @@ fn sandbox_request_wraps_native_argv_on_executor() {
|
||||
env: HashMap::new(),
|
||||
tty: false,
|
||||
pipe_stdin: false,
|
||||
ingress: None,
|
||||
arg0: None,
|
||||
sandbox: Some(sandbox),
|
||||
enforce_managed_network: false,
|
||||
@@ -81,6 +82,47 @@ fn sandbox_request_wraps_native_argv_on_executor() {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn sandbox_request_forwards_ingress_to_linux_sandbox() {
|
||||
let cwd: AbsolutePathBuf = std::env::current_dir()
|
||||
.expect("current directory")
|
||||
.try_into()
|
||||
.expect("absolute cwd");
|
||||
let cwd_uri = PathUri::from_abs_path(&cwd);
|
||||
let self_exe = std::env::current_exe().expect("current executable");
|
||||
let runtime_paths =
|
||||
ExecServerRuntimePaths::new(self_exe.clone(), Some(self_exe)).expect("runtime paths");
|
||||
let sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd(
|
||||
PermissionProfile::workspace_write(),
|
||||
cwd_uri.clone(),
|
||||
);
|
||||
let params = ExecParams {
|
||||
process_id: ProcessId::from("ingress"),
|
||||
argv: vec!["/usr/bin/true".to_string()],
|
||||
cwd: cwd_uri,
|
||||
env_policy: None,
|
||||
env: HashMap::new(),
|
||||
tty: false,
|
||||
pipe_stdin: false,
|
||||
ingress: Some(4173),
|
||||
arg0: None,
|
||||
sandbox: Some(sandbox),
|
||||
enforce_managed_network: false,
|
||||
managed_network: None,
|
||||
};
|
||||
|
||||
let prepared = prepare_exec_request(¶ms, HashMap::new(), Some(&runtime_paths))
|
||||
.expect("prepare ingress sandbox request");
|
||||
|
||||
assert!(
|
||||
prepared
|
||||
.command
|
||||
.windows(2)
|
||||
.any(|args| args[0] == "--ingress" && args[1] == "4173")
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn sandbox_request_allows_prepared_managed_proxy_port() {
|
||||
@@ -104,6 +146,7 @@ fn sandbox_request_allows_prepared_managed_proxy_port() {
|
||||
env: HashMap::new(),
|
||||
tty: false,
|
||||
pipe_stdin: false,
|
||||
ingress: None,
|
||||
arg0: None,
|
||||
sandbox: Some(sandbox),
|
||||
enforce_managed_network: true,
|
||||
@@ -140,6 +183,7 @@ fn native_request_preserves_native_launch_fields() {
|
||||
env: HashMap::new(),
|
||||
tty: false,
|
||||
pipe_stdin: false,
|
||||
ingress: None,
|
||||
arg0: Some("custom-arg0".to_string()),
|
||||
sandbox: None,
|
||||
enforce_managed_network: false,
|
||||
|
||||
@@ -33,6 +33,7 @@ fn exec_params_with_argv(process_id: &str, argv: Vec<String>) -> ExecParams {
|
||||
env: inherited_path_env(),
|
||||
tty: false,
|
||||
pipe_stdin: false,
|
||||
ingress: None,
|
||||
arg0: None,
|
||||
sandbox: None,
|
||||
enforce_managed_network: false,
|
||||
|
||||
@@ -590,6 +590,7 @@ mod tests {
|
||||
env,
|
||||
tty: false,
|
||||
pipe_stdin: false,
|
||||
ingress: None,
|
||||
arg0: None,
|
||||
sandbox: None,
|
||||
enforce_managed_network: false,
|
||||
|
||||
@@ -80,6 +80,7 @@ async fn assert_exec_process_starts_and_exits(use_remote: bool) -> Result<()> {
|
||||
env: Default::default(),
|
||||
tty: false,
|
||||
pipe_stdin: false,
|
||||
ingress: None,
|
||||
arg0: None,
|
||||
sandbox: None,
|
||||
enforce_managed_network: false,
|
||||
@@ -224,6 +225,7 @@ async fn assert_exec_process_streams_output(use_remote: bool) -> Result<()> {
|
||||
env: Default::default(),
|
||||
tty: false,
|
||||
pipe_stdin: false,
|
||||
ingress: None,
|
||||
arg0: None,
|
||||
sandbox: None,
|
||||
enforce_managed_network: false,
|
||||
@@ -258,6 +260,7 @@ async fn assert_exec_process_pushes_events(use_remote: bool) -> Result<()> {
|
||||
env: Default::default(),
|
||||
tty: false,
|
||||
pipe_stdin: false,
|
||||
ingress: None,
|
||||
arg0: None,
|
||||
sandbox: None,
|
||||
enforce_managed_network: false,
|
||||
@@ -308,6 +311,7 @@ async fn assert_exec_process_replays_events_after_close(use_remote: bool) -> Res
|
||||
env: Default::default(),
|
||||
tty: false,
|
||||
pipe_stdin: false,
|
||||
ingress: None,
|
||||
arg0: None,
|
||||
sandbox: None,
|
||||
enforce_managed_network: false,
|
||||
@@ -359,6 +363,7 @@ async fn assert_exec_process_retains_output_after_exit_until_streams_close(
|
||||
env: Default::default(),
|
||||
tty: false,
|
||||
pipe_stdin: false,
|
||||
ingress: None,
|
||||
arg0: None,
|
||||
sandbox: None,
|
||||
enforce_managed_network: false,
|
||||
@@ -435,6 +440,7 @@ async fn assert_exec_process_write_then_read(use_remote: bool) -> Result<()> {
|
||||
env: Default::default(),
|
||||
tty: true,
|
||||
pipe_stdin: false,
|
||||
ingress: None,
|
||||
arg0: None,
|
||||
sandbox: None,
|
||||
enforce_managed_network: false,
|
||||
@@ -475,6 +481,7 @@ async fn assert_exec_process_write_then_read_without_tty(use_remote: bool) -> Re
|
||||
env: Default::default(),
|
||||
tty: false,
|
||||
pipe_stdin: true,
|
||||
ingress: None,
|
||||
arg0: None,
|
||||
sandbox: None,
|
||||
enforce_managed_network: false,
|
||||
@@ -511,6 +518,7 @@ async fn assert_exec_process_rejects_write_without_pipe_stdin(use_remote: bool)
|
||||
env: Default::default(),
|
||||
tty: false,
|
||||
pipe_stdin: false,
|
||||
ingress: None,
|
||||
arg0: None,
|
||||
sandbox: None,
|
||||
enforce_managed_network: false,
|
||||
@@ -548,6 +556,7 @@ async fn assert_exec_process_signal_interrupts_process(use_remote: bool) -> Resu
|
||||
env: Default::default(),
|
||||
tty: false,
|
||||
pipe_stdin: false,
|
||||
ingress: None,
|
||||
arg0: None,
|
||||
sandbox: None,
|
||||
enforce_managed_network: false,
|
||||
@@ -604,6 +613,7 @@ async fn assert_exec_process_signal_reports_unsupported_on_windows(use_remote: b
|
||||
env: Default::default(),
|
||||
tty: false,
|
||||
pipe_stdin: false,
|
||||
ingress: None,
|
||||
arg0: None,
|
||||
sandbox: None,
|
||||
enforce_managed_network: false,
|
||||
@@ -647,6 +657,7 @@ async fn assert_exec_process_preserves_queued_events_before_subscribe(
|
||||
env: Default::default(),
|
||||
tty: false,
|
||||
pipe_stdin: false,
|
||||
ingress: None,
|
||||
arg0: None,
|
||||
sandbox: None,
|
||||
enforce_managed_network: false,
|
||||
@@ -708,6 +719,7 @@ async fn remote_exec_process_recovers_after_transport_disconnect() -> Result<()>
|
||||
]),
|
||||
tty: false,
|
||||
pipe_stdin: true,
|
||||
ingress: None,
|
||||
arg0: None,
|
||||
sandbox: None,
|
||||
enforce_managed_network: false,
|
||||
|
||||
@@ -149,6 +149,7 @@ async fn remote_environment_routes_encrypted_exec_server_rpc() -> Result<()> {
|
||||
env: HashMap::new(),
|
||||
tty: false,
|
||||
pipe_stdin: false,
|
||||
ingress: None,
|
||||
arg0: None,
|
||||
sandbox: None,
|
||||
enforce_managed_network: false,
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
use std::io;
|
||||
use std::io::Read;
|
||||
use std::io::Write;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::net::TcpListener;
|
||||
use std::net::TcpStream;
|
||||
use std::os::fd::FromRawFd;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Condvar;
|
||||
use std::sync::Mutex;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use codex_sandboxing::ingress::INGRESS_LISTENER_FD_ENV_VAR;
|
||||
|
||||
const INGRESS_BRIDGE_READY: u8 = 1;
|
||||
const TARGET_SERVER_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const TARGET_SERVER_CONNECT_RETRY_DELAY: Duration = Duration::from_millis(25);
|
||||
const CONNECTION_IDLE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const MAX_CONCURRENT_CONNECTIONS: usize = 16;
|
||||
|
||||
pub(crate) fn take_ingress_listener_fd_from_env() -> io::Result<Option<libc::c_int>> {
|
||||
let raw_fd = match std::env::var(INGRESS_LISTENER_FD_ENV_VAR) {
|
||||
Ok(raw_fd) => raw_fd,
|
||||
Err(std::env::VarError::NotPresent) => return Ok(None),
|
||||
Err(error) => return Err(io::Error::other(error)),
|
||||
};
|
||||
let listener_fd = raw_fd.parse::<libc::c_int>().map_err(|error| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("invalid ingress listener fd `{raw_fd}`: {error}"),
|
||||
)
|
||||
})?;
|
||||
if listener_fd < 0 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("invalid ingress listener fd `{raw_fd}`"),
|
||||
));
|
||||
}
|
||||
// SAFETY: inner sandbox setup is single-threaded before the final command execs.
|
||||
unsafe {
|
||||
std::env::remove_var(INGRESS_LISTENER_FD_ENV_VAR);
|
||||
}
|
||||
Ok(Some(listener_fd))
|
||||
}
|
||||
|
||||
pub(crate) fn activate_ingress(listener_fd: libc::c_int, ingress_port: u16) -> io::Result<()> {
|
||||
crate::proxy_routing::ensure_loopback_interface_up()?;
|
||||
let (read_fd, write_fd) = create_ready_pipe()?;
|
||||
// SAFETY: ingress activation runs before the sandboxed command starts threads.
|
||||
let pid = unsafe { libc::fork() };
|
||||
if pid < 0 {
|
||||
let error = io::Error::last_os_error();
|
||||
let _ = close_fd(listener_fd);
|
||||
let _ = close_fd(read_fd);
|
||||
let _ = close_fd(write_fd);
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
if pid == 0 {
|
||||
if close_fd(read_fd).is_err() {
|
||||
// SAFETY: this is the forked bridge child; `_exit` avoids unwinding through fork.
|
||||
unsafe { libc::_exit(1) };
|
||||
}
|
||||
if run_ingress_bridge(listener_fd, ingress_port, write_fd).is_err() {
|
||||
// SAFETY: this is the forked bridge child; `_exit` avoids unwinding through fork.
|
||||
unsafe { libc::_exit(1) };
|
||||
}
|
||||
// SAFETY: this is the forked bridge child after its bridge loop returns.
|
||||
unsafe { libc::_exit(0) };
|
||||
}
|
||||
|
||||
if let Err(error) = close_fd(write_fd) {
|
||||
let _ = close_fd(listener_fd);
|
||||
let _ = close_fd(read_fd);
|
||||
return Err(error);
|
||||
}
|
||||
let readiness_result = wait_for_ingress_bridge_ready(read_fd);
|
||||
let close_result = close_fd(listener_fd);
|
||||
readiness_result?;
|
||||
close_result
|
||||
}
|
||||
|
||||
fn wait_for_ingress_bridge_ready(read_fd: libc::c_int) -> io::Result<()> {
|
||||
let mut ready = [0_u8; 1];
|
||||
// SAFETY: this parent owns the read end after closing the child write end.
|
||||
let mut read_file = unsafe { std::fs::File::from_raw_fd(read_fd) };
|
||||
read_file.read_exact(&mut ready).map_err(|error| {
|
||||
io::Error::new(
|
||||
error.kind(),
|
||||
format!("ingress bridge failed before acknowledging readiness: {error}"),
|
||||
)
|
||||
})?;
|
||||
if ready[0] != INGRESS_BRIDGE_READY {
|
||||
return Err(io::Error::other(
|
||||
"ingress bridge did not acknowledge readiness",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_ingress_bridge(
|
||||
listener_fd: libc::c_int,
|
||||
ingress_port: u16,
|
||||
ready_fd: libc::c_int,
|
||||
) -> io::Result<()> {
|
||||
crate::proxy_routing::harden_bridge_process()?;
|
||||
// SAFETY: the launcher transferred ownership of this inherited listener to the bridge child.
|
||||
let listener = unsafe { TcpListener::from_raw_fd(listener_fd) };
|
||||
// SAFETY: this bridge child owns the write end after closing the parent read end.
|
||||
let mut ready_file = unsafe { std::fs::File::from_raw_fd(ready_fd) };
|
||||
ready_file.write_all(&[INGRESS_BRIDGE_READY])?;
|
||||
drop(ready_file);
|
||||
|
||||
let connection_limiter = Arc::new(ConnectionLimiter::new(MAX_CONCURRENT_CONNECTIONS));
|
||||
loop {
|
||||
let connection_permit = connection_limiter.acquire();
|
||||
let (ingress_stream, _) = listener.accept()?;
|
||||
thread::spawn(move || {
|
||||
let _connection_permit = connection_permit;
|
||||
let target_stream = match connect_target_server_with_retry(ingress_port) {
|
||||
Ok(target_stream) => target_stream,
|
||||
Err(_) => return,
|
||||
};
|
||||
if set_connection_timeouts(&ingress_stream)
|
||||
.and_then(|()| set_connection_timeouts(&target_stream))
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let _ = proxy_bidirectional(ingress_stream, target_stream);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn connect_target_server_with_retry(ingress_port: u16) -> io::Result<TcpStream> {
|
||||
let deadline = Instant::now() + TARGET_SERVER_CONNECT_TIMEOUT;
|
||||
loop {
|
||||
match TcpStream::connect((Ipv4Addr::LOCALHOST, ingress_port)) {
|
||||
Ok(stream) => return Ok(stream),
|
||||
Err(_) if Instant::now() < deadline => {
|
||||
thread::sleep(TARGET_SERVER_CONNECT_RETRY_DELAY);
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn proxy_bidirectional(
|
||||
mut ingress_stream: TcpStream,
|
||||
mut target_stream: TcpStream,
|
||||
) -> io::Result<()> {
|
||||
let mut ingress_reader = ingress_stream.try_clone()?;
|
||||
let mut target_writer = target_stream.try_clone()?;
|
||||
let ingress_to_target =
|
||||
thread::spawn(move || std::io::copy(&mut ingress_reader, &mut target_writer));
|
||||
let target_to_ingress = std::io::copy(&mut target_stream, &mut ingress_stream);
|
||||
let ingress_to_target = ingress_to_target
|
||||
.join()
|
||||
.map_err(|_| io::Error::other("ingress bridge thread panicked"))?;
|
||||
ingress_to_target?;
|
||||
target_to_ingress?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_connection_timeouts(stream: &TcpStream) -> io::Result<()> {
|
||||
stream.set_read_timeout(Some(CONNECTION_IDLE_TIMEOUT))?;
|
||||
stream.set_write_timeout(Some(CONNECTION_IDLE_TIMEOUT))
|
||||
}
|
||||
|
||||
struct ConnectionLimiter {
|
||||
max_connections: usize,
|
||||
active_connections: Mutex<usize>,
|
||||
connection_available: Condvar,
|
||||
}
|
||||
|
||||
impl ConnectionLimiter {
|
||||
fn new(max_connections: usize) -> Self {
|
||||
Self {
|
||||
max_connections,
|
||||
active_connections: Mutex::new(0),
|
||||
connection_available: Condvar::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn acquire(self: &Arc<Self>) -> ConnectionPermit {
|
||||
let mut active_connections = self
|
||||
.active_connections
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
while *active_connections >= self.max_connections {
|
||||
active_connections = self
|
||||
.connection_available
|
||||
.wait(active_connections)
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
}
|
||||
*active_connections += 1;
|
||||
ConnectionPermit {
|
||||
limiter: Arc::clone(self),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ConnectionPermit {
|
||||
limiter: Arc<ConnectionLimiter>,
|
||||
}
|
||||
|
||||
impl Drop for ConnectionPermit {
|
||||
fn drop(&mut self) {
|
||||
let mut active_connections = self
|
||||
.limiter
|
||||
.active_connections
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
*active_connections -= 1;
|
||||
self.limiter.connection_available.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
fn close_fd(fd: libc::c_int) -> io::Result<()> {
|
||||
// SAFETY: callers pass a live inherited file descriptor owned by this process.
|
||||
let result = unsafe { libc::close(fd) };
|
||||
if result < 0 {
|
||||
Err(io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn create_ready_pipe() -> io::Result<(libc::c_int, libc::c_int)> {
|
||||
let mut pipe_fds = [0; 2];
|
||||
// SAFETY: `pipe_fds` points to space for exactly two file descriptors.
|
||||
let result = unsafe { libc::pipe2(pipe_fds.as_mut_ptr(), libc::O_CLOEXEC) };
|
||||
if result < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok((pipe_fds[0], pipe_fds[1]))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::mpsc;
|
||||
|
||||
#[test]
|
||||
fn listener_fd_is_taken_from_env() {
|
||||
// SAFETY: this test mutates the process environment before spawning threads.
|
||||
unsafe {
|
||||
std::env::set_var(INGRESS_LISTENER_FD_ENV_VAR, "17");
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
take_ingress_listener_fd_from_env().expect("take listener fd"),
|
||||
Some(17)
|
||||
);
|
||||
assert_eq!(
|
||||
std::env::var(INGRESS_LISTENER_FD_ENV_VAR),
|
||||
Err(std::env::VarError::NotPresent)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_limiter_waits_for_available_slot() {
|
||||
let connection_limiter = Arc::new(ConnectionLimiter::new(1));
|
||||
let first_connection = connection_limiter.acquire();
|
||||
let (acquired_tx, acquired_rx) = mpsc::channel();
|
||||
let waiting_limiter = Arc::clone(&connection_limiter);
|
||||
let waiting_connection = thread::spawn(move || {
|
||||
let _second_connection = waiting_limiter.acquire();
|
||||
acquired_tx.send(()).expect("report acquired connection");
|
||||
});
|
||||
|
||||
assert!(
|
||||
acquired_rx.recv_timeout(Duration::from_millis(50)).is_err(),
|
||||
"second connection should wait for the first connection to close"
|
||||
);
|
||||
drop(first_connection);
|
||||
acquired_rx
|
||||
.recv_timeout(Duration::from_secs(1))
|
||||
.expect("second connection should acquire released slot");
|
||||
waiting_connection.join().expect("join waiting connection");
|
||||
}
|
||||
}
|
||||
@@ -44,14 +44,14 @@ pub(crate) fn apply_permission_profile_to_current_thread(
|
||||
cwd: &Path,
|
||||
apply_landlock_fs: bool,
|
||||
allow_network_for_proxy: bool,
|
||||
proxy_routed_network: bool,
|
||||
allow_ip_sockets_in_isolated_network: bool,
|
||||
) -> Result<()> {
|
||||
let (file_system_sandbox_policy, network_sandbox_policy) =
|
||||
permission_profile.to_runtime_permissions();
|
||||
let network_seccomp_mode = network_seccomp_mode(
|
||||
network_sandbox_policy,
|
||||
allow_network_for_proxy,
|
||||
proxy_routed_network,
|
||||
allow_ip_sockets_in_isolated_network,
|
||||
);
|
||||
|
||||
// `PR_SET_NO_NEW_PRIVS` is required for seccomp, but it also prevents
|
||||
@@ -90,7 +90,7 @@ pub(crate) fn apply_permission_profile_to_current_thread(
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum NetworkSeccompMode {
|
||||
Restricted,
|
||||
ProxyRouted,
|
||||
LocalIpSockets,
|
||||
}
|
||||
|
||||
fn should_install_network_seccomp(
|
||||
@@ -105,12 +105,12 @@ fn should_install_network_seccomp(
|
||||
fn network_seccomp_mode(
|
||||
network_sandbox_policy: NetworkSandboxPolicy,
|
||||
allow_network_for_proxy: bool,
|
||||
proxy_routed_network: bool,
|
||||
allow_ip_sockets_in_isolated_network: bool,
|
||||
) -> Option<NetworkSeccompMode> {
|
||||
if !should_install_network_seccomp(network_sandbox_policy, allow_network_for_proxy) {
|
||||
None
|
||||
} else if proxy_routed_network {
|
||||
Some(NetworkSeccompMode::ProxyRouted)
|
||||
} else if allow_ip_sockets_in_isolated_network {
|
||||
Some(NetworkSeccompMode::LocalIpSockets)
|
||||
} else {
|
||||
Some(NetworkSeccompMode::Restricted)
|
||||
}
|
||||
@@ -215,13 +215,10 @@ fn install_network_seccomp_filter_on_current_thread(
|
||||
rules.insert(libc::SYS_socket, vec![unix_only_rule.clone()]);
|
||||
rules.insert(libc::SYS_socketpair, vec![unix_only_rule]);
|
||||
}
|
||||
NetworkSeccompMode::ProxyRouted => {
|
||||
// In proxy-routed mode we allow IP sockets in the isolated
|
||||
// namespace (used to reach the local TCP bridge) but deny socket()
|
||||
// for all other families, including AF_UNIX. Only AF_UNIX
|
||||
// socketpair() remains available for process-local IPC because it
|
||||
// cannot connect to a socket outside the sandbox or bypass the
|
||||
// bridge.
|
||||
NetworkSeccompMode::LocalIpSockets => {
|
||||
// Local bridges and ingress servers need IP sockets inside
|
||||
// the isolated namespace. Other socket families remain blocked,
|
||||
// except AF_UNIX socketpair() for process-local IPC.
|
||||
let deny_non_ip_socket = SeccompRule::new(vec![
|
||||
SeccompCondition::new(
|
||||
0,
|
||||
@@ -310,14 +307,26 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_proxy_routes_use_proxy_routed_seccomp_mode() {
|
||||
fn managed_proxy_routes_allow_local_ip_sockets() {
|
||||
assert_eq!(
|
||||
network_seccomp_mode(
|
||||
NetworkSandboxPolicy::Enabled,
|
||||
/*allow_network_for_proxy*/ true,
|
||||
/*proxy_routed_network*/ true,
|
||||
/*allow_ip_sockets_in_isolated_network*/ true,
|
||||
),
|
||||
Some(NetworkSeccompMode::ProxyRouted)
|
||||
Some(NetworkSeccompMode::LocalIpSockets)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingress_allows_local_ip_sockets_without_proxy() {
|
||||
assert_eq!(
|
||||
network_seccomp_mode(
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
/*allow_network_for_proxy*/ false,
|
||||
/*allow_ip_sockets_in_isolated_network*/ true,
|
||||
),
|
||||
Some(NetworkSeccompMode::LocalIpSockets)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -327,7 +336,7 @@ mod tests {
|
||||
network_seccomp_mode(
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
/*allow_network_for_proxy*/ false,
|
||||
/*proxy_routed_network*/ false,
|
||||
/*allow_ip_sockets_in_isolated_network*/ false,
|
||||
),
|
||||
Some(NetworkSeccompMode::Restricted)
|
||||
);
|
||||
@@ -339,7 +348,7 @@ mod tests {
|
||||
network_seccomp_mode(
|
||||
NetworkSandboxPolicy::Enabled,
|
||||
/*allow_network_for_proxy*/ false,
|
||||
/*proxy_routed_network*/ false,
|
||||
/*allow_ip_sockets_in_isolated_network*/ false,
|
||||
),
|
||||
None
|
||||
);
|
||||
|
||||
@@ -12,6 +12,8 @@ mod bwrap;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod exec_util;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod ingress;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod landlock;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod launcher;
|
||||
|
||||
@@ -20,6 +20,8 @@ use std::time::Duration;
|
||||
use crate::bwrap::BwrapNetworkMode;
|
||||
use crate::bwrap::BwrapOptions;
|
||||
use crate::bwrap::create_bwrap_command_args;
|
||||
use crate::ingress::activate_ingress;
|
||||
use crate::ingress::take_ingress_listener_fd_from_env;
|
||||
use crate::landlock::apply_permission_profile_to_current_thread;
|
||||
use crate::launcher::exec_bwrap;
|
||||
use crate::launcher::preferred_bwrap_supports_argv0;
|
||||
@@ -126,6 +128,10 @@ pub struct LandlockCommand {
|
||||
#[arg(long = "proxy-route-spec", hide = true)]
|
||||
pub proxy_route_spec: Option<String>,
|
||||
|
||||
/// Internal: expose a TCP port through an inherited listener.
|
||||
#[arg(long = "ingress", hide = true)]
|
||||
pub ingress: Option<u16>,
|
||||
|
||||
/// When set, skip mounting a fresh `/proc` even though PID isolation is
|
||||
/// still enabled. This is primarily intended for restrictive container
|
||||
/// environments that deny `--proc /proc`.
|
||||
@@ -153,6 +159,7 @@ pub fn run_main() -> ! {
|
||||
apply_seccomp_then_exec,
|
||||
allow_network_for_proxy,
|
||||
proxy_route_spec,
|
||||
ingress,
|
||||
no_proc,
|
||||
command,
|
||||
} = LandlockCommand::parse();
|
||||
@@ -161,6 +168,7 @@ pub fn run_main() -> ! {
|
||||
panic!("No command specified to execute.");
|
||||
}
|
||||
ensure_inner_stage_mode_is_valid(apply_seccomp_then_exec, use_legacy_landlock);
|
||||
ensure_ingress_mode_is_valid(ingress, use_legacy_landlock);
|
||||
let EffectivePermissions {
|
||||
permission_profile,
|
||||
file_system_sandbox_policy,
|
||||
@@ -176,6 +184,14 @@ pub fn run_main() -> ! {
|
||||
// Inner stage: apply seccomp/no_new_privs after bubblewrap has already
|
||||
// established the filesystem view.
|
||||
if apply_seccomp_then_exec {
|
||||
if let Some(ingress_port) = ingress {
|
||||
let listener_fd = take_ingress_listener_fd_from_env()
|
||||
.unwrap_or_else(|err| panic!("error resolving ingress listener fd: {err}"))
|
||||
.unwrap_or_else(|| panic!("ingress requires inherited listener fd"));
|
||||
if let Err(err) = activate_ingress(listener_fd, ingress_port) {
|
||||
panic!("error activating ingress bridge: {err}");
|
||||
}
|
||||
}
|
||||
if allow_network_for_proxy {
|
||||
let spec = proxy_route_spec
|
||||
.as_deref()
|
||||
@@ -184,26 +200,29 @@ pub fn run_main() -> ! {
|
||||
panic!("error activating Linux proxy routing bridge: {err}");
|
||||
}
|
||||
}
|
||||
let proxy_routing_active = allow_network_for_proxy;
|
||||
let allow_ip_sockets_in_isolated_network = allow_network_for_proxy || ingress.is_some();
|
||||
if let Err(e) = apply_permission_profile_to_current_thread(
|
||||
&permission_profile,
|
||||
&sandbox_policy_cwd,
|
||||
/*apply_landlock_fs*/ false,
|
||||
allow_network_for_proxy,
|
||||
proxy_routing_active,
|
||||
allow_ip_sockets_in_isolated_network,
|
||||
) {
|
||||
panic!("error applying Linux sandbox restrictions: {e:?}");
|
||||
}
|
||||
exec_or_panic(command);
|
||||
}
|
||||
|
||||
if file_system_sandbox_policy.has_full_disk_write_access() && !allow_network_for_proxy {
|
||||
if file_system_sandbox_policy.has_full_disk_write_access()
|
||||
&& !allow_network_for_proxy
|
||||
&& ingress.is_none()
|
||||
{
|
||||
if let Err(e) = apply_permission_profile_to_current_thread(
|
||||
&permission_profile,
|
||||
&sandbox_policy_cwd,
|
||||
/*apply_landlock_fs*/ false,
|
||||
allow_network_for_proxy,
|
||||
/*proxy_routed_network*/ false,
|
||||
/*allow_ip_sockets_in_isolated_network*/ false,
|
||||
) {
|
||||
panic!("error applying Linux sandbox restrictions: {e:?}");
|
||||
}
|
||||
@@ -228,6 +247,7 @@ pub fn run_main() -> ! {
|
||||
permission_profile: &permission_profile,
|
||||
allow_network_for_proxy,
|
||||
proxy_route_spec,
|
||||
ingress,
|
||||
command,
|
||||
});
|
||||
run_bwrap_with_proc_fallback(
|
||||
@@ -247,7 +267,7 @@ pub fn run_main() -> ! {
|
||||
&sandbox_policy_cwd,
|
||||
/*apply_landlock_fs*/ true,
|
||||
allow_network_for_proxy,
|
||||
/*proxy_routed_network*/ false,
|
||||
/*allow_ip_sockets_in_isolated_network*/ false,
|
||||
) {
|
||||
panic!("error applying legacy Linux sandbox restrictions: {e:?}");
|
||||
}
|
||||
@@ -298,6 +318,15 @@ fn ensure_inner_stage_mode_is_valid(apply_seccomp_then_exec: bool, use_legacy_la
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_ingress_mode_is_valid(ingress: Option<u16>, use_legacy_landlock: bool) {
|
||||
if ingress == Some(0) {
|
||||
panic!("--ingress requires a non-zero port");
|
||||
}
|
||||
if ingress.is_some() && use_legacy_landlock {
|
||||
panic!("--ingress requires bubblewrap mode");
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_legacy_landlock_mode_supports_policy(
|
||||
use_legacy_landlock: bool,
|
||||
file_system_sandbox_policy: &FileSystemSandboxPolicy,
|
||||
@@ -1394,6 +1423,7 @@ struct InnerSeccompCommandArgs<'a> {
|
||||
permission_profile: &'a PermissionProfile,
|
||||
allow_network_for_proxy: bool,
|
||||
proxy_route_spec: Option<String>,
|
||||
ingress: Option<u16>,
|
||||
command: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -1405,6 +1435,7 @@ fn build_inner_seccomp_command(args: InnerSeccompCommandArgs<'_>) -> Vec<String>
|
||||
permission_profile,
|
||||
allow_network_for_proxy,
|
||||
proxy_route_spec,
|
||||
ingress,
|
||||
command,
|
||||
} = args;
|
||||
let current_exe = match std::env::current_exe() {
|
||||
@@ -1437,6 +1468,10 @@ fn build_inner_seccomp_command(args: InnerSeccompCommandArgs<'_>) -> Vec<String>
|
||||
inner.push("--proxy-route-spec".to_string());
|
||||
inner.push(proxy_route_spec);
|
||||
}
|
||||
if let Some(ingress_port) = ingress {
|
||||
inner.push("--ingress".to_string());
|
||||
inner.push(ingress_port.to_string());
|
||||
}
|
||||
inner.push("--".to_string());
|
||||
inner.extend(command);
|
||||
inner
|
||||
|
||||
@@ -468,6 +468,7 @@ fn managed_proxy_inner_command_includes_route_spec() {
|
||||
permission_profile: &permission_profile,
|
||||
allow_network_for_proxy: true,
|
||||
proxy_route_spec: Some("{\"routes\":[]}".to_string()),
|
||||
ingress: None,
|
||||
command: vec!["/bin/true".to_string()],
|
||||
});
|
||||
|
||||
@@ -484,6 +485,7 @@ fn inner_command_includes_permission_profile_flag() {
|
||||
permission_profile: &permission_profile,
|
||||
allow_network_for_proxy: false,
|
||||
proxy_route_spec: None,
|
||||
ingress: None,
|
||||
command: vec!["/bin/true".to_string()],
|
||||
});
|
||||
|
||||
@@ -503,12 +505,32 @@ fn non_managed_inner_command_omits_route_spec() {
|
||||
permission_profile: &permission_profile,
|
||||
allow_network_for_proxy: false,
|
||||
proxy_route_spec: None,
|
||||
ingress: None,
|
||||
command: vec!["/bin/true".to_string()],
|
||||
});
|
||||
|
||||
assert!(!args.iter().any(|arg| arg == "--proxy-route-spec"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingress_inner_command_includes_bridge_flag() {
|
||||
let permission_profile = read_only_permission_profile();
|
||||
let args = build_inner_seccomp_command(InnerSeccompCommandArgs {
|
||||
sandbox_policy_cwd: Path::new("/tmp"),
|
||||
command_cwd: Some(Path::new("/tmp/link")),
|
||||
permission_profile: &permission_profile,
|
||||
allow_network_for_proxy: false,
|
||||
proxy_route_spec: None,
|
||||
ingress: Some(4173),
|
||||
command: vec!["/bin/true".to_string()],
|
||||
});
|
||||
|
||||
assert!(
|
||||
args.windows(2)
|
||||
.any(|args| args[0] == "--ingress" && args[1] == "4173")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_proxy_inner_command_requires_route_spec() {
|
||||
let result = std::panic::catch_unwind(|| {
|
||||
@@ -519,6 +541,7 @@ fn managed_proxy_inner_command_requires_route_spec() {
|
||||
permission_profile: &permission_profile,
|
||||
allow_network_for_proxy: true,
|
||||
proxy_route_spec: None,
|
||||
ingress: None,
|
||||
command: vec!["/bin/true".to_string()],
|
||||
})
|
||||
});
|
||||
|
||||
@@ -563,7 +563,7 @@ fn bind_local_loopback_listener() -> io::Result<TcpListener> {
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_loopback_interface_up() -> io::Result<()> {
|
||||
pub(crate) fn ensure_loopback_interface_up() -> io::Result<()> {
|
||||
let fd = unsafe { libc::socket(libc::AF_INET, libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, 0) };
|
||||
if fd < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
@@ -636,7 +636,7 @@ fn set_parent_death_signal() -> io::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
fn harden_bridge_process() -> io::Result<()> {
|
||||
pub(crate) fn harden_bridge_process() -> io::Result<()> {
|
||||
set_parent_death_signal()?;
|
||||
codex_process_hardening::disable_process_dumping()
|
||||
}
|
||||
|
||||
@@ -4,12 +4,16 @@
|
||||
use codex_core::exec_env::create_env;
|
||||
use codex_protocol::config_types::ShellEnvironmentPolicy;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_sandboxing::ingress::INGRESS_LISTENER_FD_ENV_VAR;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashMap;
|
||||
use std::io::Read;
|
||||
use std::io::Write;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::net::Shutdown;
|
||||
use std::net::TcpListener;
|
||||
use std::net::TcpStream;
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::process::Output;
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
@@ -22,6 +26,8 @@ const MANAGED_PROXY_PERMISSION_ERR_SNIPPETS: &[&str] = &[
|
||||
"loopback: Failed RTM_NEWLINK",
|
||||
"setting up uid map: Permission denied",
|
||||
"No permissions to create a new namespace",
|
||||
"Creating new namespace failed: Operation not permitted",
|
||||
"SeccompInstall(Seccomp(Os { code: 22",
|
||||
"error isolating Linux network namespace for proxy mode",
|
||||
];
|
||||
|
||||
@@ -112,6 +118,37 @@ async fn managed_proxy_skip_reason() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn ingress_skip_reason() -> Option<String> {
|
||||
if should_skip_bwrap_tests().await {
|
||||
return Some("bubblewrap is unavailable in this environment".to_string());
|
||||
}
|
||||
|
||||
let mut env = create_env_from_core_vars();
|
||||
strip_proxy_env(&mut env);
|
||||
|
||||
let output = run_linux_sandbox_direct(
|
||||
&["bash", "-c", "true"],
|
||||
&PermissionProfile::read_only(),
|
||||
/*allow_network_for_proxy*/ false,
|
||||
env,
|
||||
NETWORK_TIMEOUT_MS,
|
||||
)
|
||||
.await;
|
||||
if output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if is_managed_proxy_permission_error(stderr.as_ref()) {
|
||||
return Some(format!(
|
||||
"ingress requires kernel namespace privileges unavailable here: {}",
|
||||
stderr.trim()
|
||||
));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
async fn run_linux_sandbox_direct(
|
||||
command: &[&str],
|
||||
permission_profile: &PermissionProfile,
|
||||
@@ -119,6 +156,26 @@ async fn run_linux_sandbox_direct(
|
||||
env: HashMap<String, String>,
|
||||
timeout_ms: u64,
|
||||
) -> Output {
|
||||
let mut cmd = linux_sandbox_command(
|
||||
command,
|
||||
permission_profile,
|
||||
allow_network_for_proxy,
|
||||
/*ingress*/ None,
|
||||
env,
|
||||
);
|
||||
tokio::time::timeout(Duration::from_millis(timeout_ms), cmd.output())
|
||||
.await
|
||||
.expect("sandbox command should not time out")
|
||||
.expect("sandbox command should execute")
|
||||
}
|
||||
|
||||
fn linux_sandbox_command(
|
||||
command: &[&str],
|
||||
permission_profile: &PermissionProfile,
|
||||
allow_network_for_proxy: bool,
|
||||
ingress: Option<u16>,
|
||||
env: HashMap<String, String>,
|
||||
) -> Command {
|
||||
let cwd = std::env::current_dir().expect("current directory should exist");
|
||||
let permission_profile_json =
|
||||
serde_json::to_string(permission_profile).expect("permission profile should serialize");
|
||||
@@ -132,6 +189,10 @@ async fn run_linux_sandbox_direct(
|
||||
if allow_network_for_proxy {
|
||||
args.push("--allow-network-for-proxy".to_string());
|
||||
}
|
||||
if let Some(ingress_port) = ingress {
|
||||
args.push("--ingress".to_string());
|
||||
args.push(ingress_port.to_string());
|
||||
}
|
||||
args.push("--".to_string());
|
||||
args.extend(command.iter().map(|entry| (*entry).to_string()));
|
||||
|
||||
@@ -143,10 +204,29 @@ async fn run_linux_sandbox_direct(
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
tokio::time::timeout(Duration::from_millis(timeout_ms), cmd.output())
|
||||
.await
|
||||
.expect("sandbox command should not time out")
|
||||
.expect("sandbox command should execute")
|
||||
cmd
|
||||
}
|
||||
|
||||
fn clear_close_on_exec(fd: libc::c_int) {
|
||||
// SAFETY: `fd` comes from this process's live `TcpListener`.
|
||||
let fd_flags = unsafe { libc::fcntl(fd, libc::F_GETFD) };
|
||||
assert!(fd_flags >= 0, "listener fd flags should be readable");
|
||||
// SAFETY: `fd` comes from this process's live `TcpListener`.
|
||||
let result = unsafe { libc::fcntl(fd, libc::F_SETFD, fd_flags & !libc::FD_CLOEXEC) };
|
||||
assert_eq!(result, 0, "listener fd should be inherited");
|
||||
}
|
||||
|
||||
fn connect_ingress_stream(port: u16) -> TcpStream {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
match TcpStream::connect((Ipv4Addr::LOCALHOST, port)) {
|
||||
Ok(stream) => return stream,
|
||||
Err(_) if std::time::Instant::now() < deadline => {
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
Err(error) => panic!("client should connect to ingress listener: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -301,3 +381,106 @@ async fn managed_proxy_mode_denies_af_unix_socket_but_allows_socketpair() {
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ingress_routes_parent_connection_into_sandbox_local_server() {
|
||||
if let Some(skip_reason) = ingress_skip_reason().await {
|
||||
eprintln!("skipping ingress test: {skip_reason}");
|
||||
return;
|
||||
}
|
||||
|
||||
let python_available = Command::new("bash")
|
||||
.arg("-c")
|
||||
.arg("command -v python3 >/dev/null")
|
||||
.status()
|
||||
.await
|
||||
.expect("python3 probe should execute")
|
||||
.success();
|
||||
if !python_available {
|
||||
eprintln!("skipping ingress test: python3 is unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
let listener =
|
||||
TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind parent-visible ingress listener");
|
||||
clear_close_on_exec(listener.as_raw_fd());
|
||||
let parent_visible_port = listener
|
||||
.local_addr()
|
||||
.expect("ingress listener local addr")
|
||||
.port();
|
||||
let ingress_port = 4173;
|
||||
|
||||
let mut env = create_env_from_core_vars();
|
||||
strip_proxy_env(&mut env);
|
||||
env.insert(
|
||||
INGRESS_LISTENER_FD_ENV_VAR.to_string(),
|
||||
listener.as_raw_fd().to_string(),
|
||||
);
|
||||
|
||||
let server_script = format!(
|
||||
"import http.server\n\
|
||||
class Handler(http.server.BaseHTTPRequestHandler):\n\
|
||||
def do_GET(self):\n\
|
||||
body = b'ingress-ok'\n\
|
||||
self.send_response(200)\n\
|
||||
self.send_header('Content-Length', str(len(body)))\n\
|
||||
self.end_headers()\n\
|
||||
self.wfile.write(body)\n\
|
||||
def log_message(self, format, *args):\n\
|
||||
pass\n\
|
||||
server = http.server.ThreadingHTTPServer(('127.0.0.1', {ingress_port}), Handler)\n\
|
||||
server.handle_request()\n"
|
||||
);
|
||||
let command = ["python3", "-c", server_script.as_str()];
|
||||
let mut cmd = linux_sandbox_command(
|
||||
&command,
|
||||
&PermissionProfile::read_only(),
|
||||
/*allow_network_for_proxy*/ false,
|
||||
Some(ingress_port),
|
||||
env,
|
||||
);
|
||||
cmd.kill_on_drop(true);
|
||||
let child = cmd.spawn().expect("ingress sandbox should spawn");
|
||||
|
||||
let mut ingress_stream = connect_ingress_stream(parent_visible_port);
|
||||
ingress_stream
|
||||
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||
.expect("set ingress read timeout");
|
||||
ingress_stream
|
||||
.write_all(b"GET / HTTP/1.1\r\nHost: terminal.local\r\nConnection: close\r\n\r\n")
|
||||
.expect("write ingress request");
|
||||
ingress_stream
|
||||
.shutdown(Shutdown::Write)
|
||||
.expect("close ingress request body");
|
||||
let mut response = Vec::new();
|
||||
let response_result = ingress_stream.read_to_end(&mut response);
|
||||
drop(ingress_stream);
|
||||
|
||||
let output = tokio::time::timeout(
|
||||
Duration::from_millis(NETWORK_TIMEOUT_MS),
|
||||
child.wait_with_output(),
|
||||
)
|
||||
.await
|
||||
.expect("ingress sandbox should exit after one request")
|
||||
.expect("ingress sandbox should execute");
|
||||
response_result.unwrap_or_else(|error| {
|
||||
panic!(
|
||||
"read ingress response: {error}; status={:?}; stdout={}; stderr={}",
|
||||
output.status.code(),
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)
|
||||
});
|
||||
let response = String::from_utf8_lossy(&response);
|
||||
assert!(
|
||||
response.contains("ingress-ok"),
|
||||
"expected ingress response body, got response: {response}"
|
||||
);
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"expected ingress sandbox to execute successfully; status={:?}; stdout={}; stderr={}",
|
||||
output.status.code(),
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -506,6 +506,7 @@ impl ExecutorStdioServerLauncher {
|
||||
env,
|
||||
tty: false,
|
||||
pipe_stdin: true,
|
||||
ingress: None,
|
||||
arg0: None,
|
||||
sandbox: None,
|
||||
enforce_managed_network: false,
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::net::TcpListener;
|
||||
use std::os::fd::AsRawFd;
|
||||
|
||||
/// Environment variable carrying the inherited ingress listener fd.
|
||||
pub const INGRESS_LISTENER_FD_ENV_VAR: &str = "CODEX_INGRESS_LISTENER_FD";
|
||||
|
||||
/// Parent-owned TCP ingress for one sandboxed process.
|
||||
pub struct IngressListener {
|
||||
listener: TcpListener,
|
||||
}
|
||||
|
||||
/// Error returned while preparing process ingress.
|
||||
#[derive(Debug)]
|
||||
pub enum IngressListenerError {
|
||||
InvalidPort,
|
||||
PortInUse(u16),
|
||||
Io(io::Error),
|
||||
}
|
||||
|
||||
impl IngressListener {
|
||||
/// Bind the requested parent-visible TCP port when ingress is enabled.
|
||||
pub fn prepare(ingress: Option<u16>) -> Result<Option<Self>, IngressListenerError> {
|
||||
if ingress == Some(0) {
|
||||
return Err(IngressListenerError::InvalidPort);
|
||||
}
|
||||
ingress.map(Self::bind).transpose()
|
||||
}
|
||||
|
||||
/// Add the inherited listener fd consumed by `codex-linux-sandbox`.
|
||||
pub fn add_to_child_env(&self, env: &mut HashMap<String, String>) {
|
||||
env.insert(
|
||||
INGRESS_LISTENER_FD_ENV_VAR.to_string(),
|
||||
self.listener.as_raw_fd().to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Return the listener fd that the spawned sandbox helper must preserve.
|
||||
pub fn inherited_fd(&self) -> i32 {
|
||||
self.listener.as_raw_fd()
|
||||
}
|
||||
|
||||
fn bind(port: u16) -> Result<Self, IngressListenerError> {
|
||||
let listener = TcpListener::bind((Ipv4Addr::UNSPECIFIED, port)).map_err(|error| {
|
||||
if error.kind() == io::ErrorKind::AddrInUse {
|
||||
IngressListenerError::PortInUse(port)
|
||||
} else {
|
||||
IngressListenerError::Io(error)
|
||||
}
|
||||
})?;
|
||||
set_close_on_exec(listener.as_raw_fd())?;
|
||||
Ok(Self { listener })
|
||||
}
|
||||
}
|
||||
|
||||
impl From<io::Error> for IngressListenerError {
|
||||
fn from(error: io::Error) -> Self {
|
||||
Self::Io(error)
|
||||
}
|
||||
}
|
||||
|
||||
fn set_close_on_exec(fd: libc::c_int) -> io::Result<()> {
|
||||
// SAFETY: `fd` comes from this process's live `TcpListener`.
|
||||
let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) };
|
||||
if flags < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
|
||||
// SAFETY: `fd` comes from this process's live `TcpListener`.
|
||||
let result = unsafe { libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) };
|
||||
if result < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn listener_exports_inherited_fd_to_child_env() {
|
||||
let listener = IngressListener::bind(0).expect("bind ingress listener");
|
||||
let mut env = HashMap::new();
|
||||
|
||||
listener.add_to_child_env(&mut env);
|
||||
|
||||
assert_eq!(
|
||||
env.get(INGRESS_LISTENER_FD_ENV_VAR),
|
||||
Some(&listener.inherited_fd().to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listener_keeps_close_on_exec_in_parent() {
|
||||
let listener = IngressListener::bind(0).expect("bind ingress listener");
|
||||
|
||||
assert_ne!(fd_flags(listener.inherited_fd()) & libc::FD_CLOEXEC, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listener_rejects_ephemeral_ingress_port() {
|
||||
assert!(matches!(
|
||||
IngressListener::prepare(Some(0)),
|
||||
Err(IngressListenerError::InvalidPort)
|
||||
));
|
||||
}
|
||||
|
||||
fn fd_flags(fd: libc::c_int) -> libc::c_int {
|
||||
// SAFETY: `fd` comes from this test's live `TcpListener`.
|
||||
let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) };
|
||||
assert!(flags >= 0, "read fd flags: {}", io::Error::last_os_error());
|
||||
flags
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ pub fn create_linux_sandbox_command_args_for_permission_profile(
|
||||
sandbox_policy_cwd: &Path,
|
||||
use_legacy_landlock: bool,
|
||||
allow_network_for_proxy: bool,
|
||||
ingress: Option<u16>,
|
||||
) -> Vec<String> {
|
||||
let permission_profile_json = serde_json::to_string(permission_profile)
|
||||
.unwrap_or_else(|err| panic!("failed to serialize permission profile: {err}"));
|
||||
@@ -47,13 +48,17 @@ pub fn create_linux_sandbox_command_args_for_permission_profile(
|
||||
"--permission-profile".to_string(),
|
||||
permission_profile_json,
|
||||
];
|
||||
// Proxy-only networking requires bubblewrap's isolated network namespace.
|
||||
if use_legacy_landlock && !allow_network_for_proxy {
|
||||
// Network bridges require bubblewrap's isolated network namespace.
|
||||
if use_legacy_landlock && !allow_network_for_proxy && ingress.is_none() {
|
||||
linux_cmd.push("--use-legacy-landlock".to_string());
|
||||
}
|
||||
if allow_network_for_proxy {
|
||||
linux_cmd.push("--allow-network-for-proxy".to_string());
|
||||
}
|
||||
if let Some(ingress_port) = ingress {
|
||||
linux_cmd.push("--ingress".to_string());
|
||||
linux_cmd.push(ingress_port.to_string());
|
||||
}
|
||||
linux_cmd.push("--".to_string());
|
||||
linux_cmd.extend(command);
|
||||
linux_cmd
|
||||
@@ -68,6 +73,7 @@ fn create_linux_sandbox_command_args(
|
||||
sandbox_policy_cwd: &Path,
|
||||
use_legacy_landlock: bool,
|
||||
allow_network_for_proxy: bool,
|
||||
ingress: Option<u16>,
|
||||
) -> Vec<String> {
|
||||
let command_cwd = command_cwd
|
||||
.to_str()
|
||||
@@ -84,13 +90,17 @@ fn create_linux_sandbox_command_args(
|
||||
"--command-cwd".to_string(),
|
||||
command_cwd,
|
||||
];
|
||||
// Proxy-only networking requires bubblewrap's isolated network namespace.
|
||||
if use_legacy_landlock && !allow_network_for_proxy {
|
||||
// Network bridges require bubblewrap's isolated network namespace.
|
||||
if use_legacy_landlock && !allow_network_for_proxy && ingress.is_none() {
|
||||
linux_cmd.push("--use-legacy-landlock".to_string());
|
||||
}
|
||||
if allow_network_for_proxy {
|
||||
linux_cmd.push("--allow-network-for-proxy".to_string());
|
||||
}
|
||||
if let Some(ingress_port) = ingress {
|
||||
linux_cmd.push("--ingress".to_string());
|
||||
linux_cmd.push(ingress_port.to_string());
|
||||
}
|
||||
|
||||
// Separator so that command arguments starting with `-` are not parsed as
|
||||
// options of the helper itself.
|
||||
|
||||
@@ -13,6 +13,7 @@ fn legacy_landlock_flag_is_included_when_requested() {
|
||||
cwd,
|
||||
/*use_legacy_landlock*/ false,
|
||||
/*allow_network_for_proxy*/ false,
|
||||
/*ingress*/ None,
|
||||
);
|
||||
assert_eq!(
|
||||
default_bwrap.contains(&"--use-legacy-landlock".to_string()),
|
||||
@@ -25,6 +26,7 @@ fn legacy_landlock_flag_is_included_when_requested() {
|
||||
cwd,
|
||||
/*use_legacy_landlock*/ true,
|
||||
/*allow_network_for_proxy*/ false,
|
||||
/*ingress*/ None,
|
||||
);
|
||||
assert_eq!(
|
||||
legacy_landlock.contains(&"--use-legacy-landlock".to_string()),
|
||||
@@ -46,6 +48,7 @@ fn proxy_flag_takes_precedence_over_legacy_landlock() {
|
||||
cwd,
|
||||
/*use_legacy_landlock*/ true,
|
||||
/*allow_network_for_proxy*/ true,
|
||||
/*ingress*/ None,
|
||||
);
|
||||
assert_eq!(
|
||||
args.contains(&"--allow-network-for-proxy".to_string()),
|
||||
@@ -68,6 +71,7 @@ fn permission_profile_flag_is_included() {
|
||||
cwd,
|
||||
/*use_legacy_landlock*/ true,
|
||||
/*allow_network_for_proxy*/ false,
|
||||
/*ingress*/ None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
@@ -93,3 +97,25 @@ fn proxy_network_requires_managed_requirements() {
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingress_forces_bubblewrap_bridge_flag() {
|
||||
let command = vec!["/bin/true".to_string()];
|
||||
let command_cwd = Path::new("/tmp/link");
|
||||
let cwd = Path::new("/tmp");
|
||||
|
||||
let args = create_linux_sandbox_command_args(
|
||||
command,
|
||||
command_cwd,
|
||||
cwd,
|
||||
/*use_legacy_landlock*/ true,
|
||||
/*allow_network_for_proxy*/ false,
|
||||
/*ingress*/ Some(4173),
|
||||
);
|
||||
|
||||
assert!(
|
||||
args.windows(2)
|
||||
.any(|args| args[0] == "--ingress" && args[1] == "4173")
|
||||
);
|
||||
assert_eq!(args.contains(&"--use-legacy-landlock".to_string()), false);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#[cfg(target_os = "linux")]
|
||||
mod bwrap;
|
||||
mod denial;
|
||||
#[cfg(unix)]
|
||||
pub mod ingress;
|
||||
pub mod landlock;
|
||||
mod manager;
|
||||
pub mod policy_transforms;
|
||||
@@ -54,6 +56,15 @@ impl From<SandboxTransformError> for CodexErr {
|
||||
SandboxTransformError::EnvironmentNetworkProxy(message) => {
|
||||
CodexErr::UnsupportedOperation(message)
|
||||
}
|
||||
SandboxTransformError::IngressRequiresLinuxSandbox => {
|
||||
CodexErr::UnsupportedOperation("ingress requires the Linux sandbox".to_string())
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
SandboxTransformError::IngressRequiresIsolatedNetwork => {
|
||||
CodexErr::UnsupportedOperation(
|
||||
"ingress requires isolated or managed-proxy Linux networking".to_string(),
|
||||
)
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
SandboxTransformError::Wsl1UnsupportedForBubblewrap => {
|
||||
CodexErr::UnsupportedOperation(crate::bwrap::WSL1_BWRAP_WARNING.to_string())
|
||||
|
||||
@@ -114,6 +114,7 @@ pub struct SandboxExecRequest {
|
||||
pub cwd: PathUri,
|
||||
pub sandbox_policy_cwd: PathUri,
|
||||
pub env: HashMap<String, String>,
|
||||
pub ingress: Option<u16>,
|
||||
pub network: Option<NetworkProxy>,
|
||||
pub network_environment_id: Option<String>,
|
||||
pub sandbox: SandboxType,
|
||||
@@ -137,6 +138,7 @@ pub struct SandboxTransformRequest<'a> {
|
||||
// TODO(viyatb): Evaluate switching this to Option<Arc<NetworkProxy>>
|
||||
// to make shared ownership explicit across runtime/sandbox plumbing.
|
||||
pub network: Option<&'a NetworkProxy>,
|
||||
pub ingress: Option<u16>,
|
||||
pub sandbox_policy_cwd: &'a PathUri,
|
||||
pub codex_linux_sandbox_exe: Option<&'a Path>,
|
||||
pub use_legacy_landlock: bool,
|
||||
@@ -213,6 +215,9 @@ pub enum SandboxTransformError {
|
||||
},
|
||||
MissingLinuxSandboxExecutable,
|
||||
EnvironmentNetworkProxy(String),
|
||||
IngressRequiresLinuxSandbox,
|
||||
#[cfg(target_os = "linux")]
|
||||
IngressRequiresIsolatedNetwork,
|
||||
#[cfg(target_os = "linux")]
|
||||
Wsl1UnsupportedForBubblewrap,
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
@@ -240,6 +245,14 @@ impl std::fmt::Display for SandboxTransformError {
|
||||
Self::EnvironmentNetworkProxy(err) => {
|
||||
write!(f, "failed to prepare environment network proxy: {err}")
|
||||
}
|
||||
Self::IngressRequiresLinuxSandbox => {
|
||||
write!(f, "ingress requires the Linux sandbox")
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
Self::IngressRequiresIsolatedNetwork => write!(
|
||||
f,
|
||||
"ingress requires isolated or managed-proxy Linux networking"
|
||||
),
|
||||
#[cfg(target_os = "linux")]
|
||||
Self::Wsl1UnsupportedForBubblewrap => write!(f, "{WSL1_BWRAP_WARNING}"),
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
@@ -259,6 +272,9 @@ impl std::error::Error for SandboxTransformError {
|
||||
| Self::InvalidSandboxPolicyCwd { source, .. } => Some(source),
|
||||
Self::MissingLinuxSandboxExecutable => None,
|
||||
Self::EnvironmentNetworkProxy(_) => None,
|
||||
Self::IngressRequiresLinuxSandbox => None,
|
||||
#[cfg(target_os = "linux")]
|
||||
Self::IngressRequiresIsolatedNetwork => None,
|
||||
#[cfg(target_os = "linux")]
|
||||
Self::Wsl1UnsupportedForBubblewrap => None,
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
@@ -329,6 +345,7 @@ impl SandboxManager {
|
||||
enforce_managed_network,
|
||||
environment_id,
|
||||
network,
|
||||
ingress,
|
||||
sandbox_policy_cwd,
|
||||
codex_linux_sandbox_exe,
|
||||
use_legacy_landlock,
|
||||
@@ -350,6 +367,9 @@ impl SandboxManager {
|
||||
);
|
||||
let (base_file_system_policy, base_network_policy) =
|
||||
base_effective_permission_profile.to_runtime_permissions();
|
||||
if ingress.is_some() && sandbox != SandboxType::LinuxSeccomp {
|
||||
return Err(SandboxTransformError::IngressRequiresLinuxSandbox);
|
||||
}
|
||||
let mut argv = Vec::with_capacity(1 + command.args.len());
|
||||
argv.push(command.program);
|
||||
argv.extend(command.args.into_iter().map(OsString::from));
|
||||
@@ -392,8 +412,15 @@ impl SandboxManager {
|
||||
&pending.effective_file_system_policy,
|
||||
use_legacy_landlock,
|
||||
allow_proxy_network,
|
||||
ingress,
|
||||
is_wsl1(),
|
||||
)?;
|
||||
#[cfg(target_os = "linux")]
|
||||
ensure_ingress_has_isolated_network(
|
||||
ingress,
|
||||
pending.effective_network_policy,
|
||||
allow_proxy_network,
|
||||
)?;
|
||||
let mut args = create_linux_sandbox_command_args_for_permission_profile(
|
||||
os_argv_to_strings(argv),
|
||||
pending.native_command_cwd.as_path(),
|
||||
@@ -401,6 +428,7 @@ impl SandboxManager {
|
||||
pending.native_sandbox_policy_cwd.as_path(),
|
||||
use_legacy_landlock,
|
||||
allow_proxy_network,
|
||||
ingress,
|
||||
);
|
||||
let mut full_command = Vec::with_capacity(1 + args.len());
|
||||
full_command.push(os_string_to_command_component(exe.as_os_str().to_owned()));
|
||||
@@ -449,6 +477,7 @@ impl SandboxManager {
|
||||
cwd: command.cwd,
|
||||
sandbox_policy_cwd: sandbox_policy_cwd.clone(),
|
||||
env: command.env,
|
||||
ingress,
|
||||
network: network.cloned(),
|
||||
network_environment_id: environment_id.map(str::to_string),
|
||||
sandbox,
|
||||
@@ -665,9 +694,11 @@ fn ensure_linux_bubblewrap_is_supported(
|
||||
file_system_sandbox_policy: &FileSystemSandboxPolicy,
|
||||
use_legacy_landlock: bool,
|
||||
allow_network_for_proxy: bool,
|
||||
ingress: Option<u16>,
|
||||
is_wsl1: bool,
|
||||
) -> Result<(), SandboxTransformError> {
|
||||
let requires_bubblewrap = allow_network_for_proxy
|
||||
|| ingress.is_some()
|
||||
|| (!use_legacy_landlock && !file_system_sandbox_policy.has_full_disk_write_access());
|
||||
if is_wsl1 && requires_bubblewrap {
|
||||
return Err(SandboxTransformError::Wsl1UnsupportedForBubblewrap);
|
||||
@@ -676,6 +707,19 @@ fn ensure_linux_bubblewrap_is_supported(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn ensure_ingress_has_isolated_network(
|
||||
ingress: Option<u16>,
|
||||
network_sandbox_policy: NetworkSandboxPolicy,
|
||||
allow_network_for_proxy: bool,
|
||||
) -> Result<(), SandboxTransformError> {
|
||||
if ingress.is_some() && network_sandbox_policy.is_enabled() && !allow_network_for_proxy {
|
||||
return Err(SandboxTransformError::IngressRequiresIsolatedNetwork);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn os_argv_to_strings(argv: Vec<OsString>) -> Vec<String> {
|
||||
argv.into_iter()
|
||||
.map(os_string_to_command_component)
|
||||
|
||||
@@ -100,6 +100,7 @@ fn unsandboxed_transform_preserves_foreign_cwd_and_unrestricted_file_system_poli
|
||||
enforce_managed_network: false,
|
||||
environment_id: None,
|
||||
network: None,
|
||||
ingress: None,
|
||||
sandbox_policy_cwd: &cwd_uri,
|
||||
codex_linux_sandbox_exe: None,
|
||||
use_legacy_landlock: false,
|
||||
@@ -156,6 +157,7 @@ fn transform_additional_permissions_enable_network_for_external_sandbox() {
|
||||
enforce_managed_network: false,
|
||||
environment_id: None,
|
||||
network: None,
|
||||
ingress: None,
|
||||
sandbox_policy_cwd: &cwd_uri,
|
||||
codex_linux_sandbox_exe: None,
|
||||
use_legacy_landlock: false,
|
||||
@@ -227,6 +229,7 @@ fn transform_additional_permissions_preserves_denied_entries() {
|
||||
enforce_managed_network: false,
|
||||
environment_id: None,
|
||||
network: None,
|
||||
ingress: None,
|
||||
sandbox_policy_cwd: &cwd_uri,
|
||||
codex_linux_sandbox_exe: None,
|
||||
use_legacy_landlock: false,
|
||||
@@ -325,6 +328,7 @@ fn transform_linux_seccomp_request(
|
||||
enforce_managed_network: false,
|
||||
environment_id: None,
|
||||
network: None,
|
||||
ingress: None,
|
||||
sandbox_policy_cwd: &cwd_uri,
|
||||
codex_linux_sandbox_exe: Some(codex_linux_sandbox_exe),
|
||||
use_legacy_landlock: false,
|
||||
@@ -349,6 +353,7 @@ fn wsl1_rejects_linux_bubblewrap_path() {
|
||||
&restricted_policy,
|
||||
/*use_legacy_landlock*/ false,
|
||||
/*allow_network_for_proxy*/ false,
|
||||
/*ingress*/ None,
|
||||
/*is_wsl1*/ true,
|
||||
),
|
||||
Err(super::SandboxTransformError::Wsl1UnsupportedForBubblewrap)
|
||||
@@ -358,6 +363,7 @@ fn wsl1_rejects_linux_bubblewrap_path() {
|
||||
&FileSystemSandboxPolicy::unrestricted(),
|
||||
/*use_legacy_landlock*/ false,
|
||||
/*allow_network_for_proxy*/ true,
|
||||
/*ingress*/ None,
|
||||
/*is_wsl1*/ true,
|
||||
),
|
||||
Err(super::SandboxTransformError::Wsl1UnsupportedForBubblewrap)
|
||||
@@ -367,6 +373,7 @@ fn wsl1_rejects_linux_bubblewrap_path() {
|
||||
&FileSystemSandboxPolicy::unrestricted(),
|
||||
/*use_legacy_landlock*/ true,
|
||||
/*allow_network_for_proxy*/ true,
|
||||
/*ingress*/ None,
|
||||
/*is_wsl1*/ true,
|
||||
),
|
||||
Err(super::SandboxTransformError::Wsl1UnsupportedForBubblewrap)
|
||||
@@ -381,6 +388,7 @@ fn wsl1_allows_non_bubblewrap_linux_paths() {
|
||||
&FileSystemSandboxPolicy::unrestricted(),
|
||||
/*use_legacy_landlock*/ false,
|
||||
/*allow_network_for_proxy*/ false,
|
||||
/*ingress*/ None,
|
||||
/*is_wsl1*/ true,
|
||||
)
|
||||
.is_ok()
|
||||
@@ -397,12 +405,38 @@ fn wsl1_allows_non_bubblewrap_linux_paths() {
|
||||
&restricted_policy,
|
||||
/*use_legacy_landlock*/ true,
|
||||
/*allow_network_for_proxy*/ false,
|
||||
/*ingress*/ None,
|
||||
/*is_wsl1*/ true,
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn ingress_requires_isolated_or_managed_proxy_network() {
|
||||
assert!(matches!(
|
||||
super::ensure_ingress_has_isolated_network(
|
||||
/*ingress*/ Some(4173),
|
||||
NetworkSandboxPolicy::Enabled,
|
||||
/*allow_network_for_proxy*/ false,
|
||||
),
|
||||
Err(super::SandboxTransformError::IngressRequiresIsolatedNetwork)
|
||||
));
|
||||
super::ensure_ingress_has_isolated_network(
|
||||
/*ingress*/ Some(4173),
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
/*allow_network_for_proxy*/ false,
|
||||
)
|
||||
.expect("restricted network uses an isolated namespace");
|
||||
super::ensure_ingress_has_isolated_network(
|
||||
/*ingress*/ Some(4173),
|
||||
NetworkSandboxPolicy::Enabled,
|
||||
/*allow_network_for_proxy*/ true,
|
||||
)
|
||||
.expect("managed proxy uses an isolated namespace");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn transform_linux_seccomp_preserves_helper_path_in_arg0_when_available() {
|
||||
@@ -518,6 +552,7 @@ fn transform_for_direct_spawn_windows_materializes_inner_helper() {
|
||||
enforce_managed_network: false,
|
||||
environment_id: None,
|
||||
network: None,
|
||||
ingress: None,
|
||||
sandbox_policy_cwd: &cwd_uri,
|
||||
codex_linux_sandbox_exe: None,
|
||||
use_legacy_landlock: false,
|
||||
|
||||
@@ -140,6 +140,7 @@ async fn spawn_process_with_stdin_mode(
|
||||
crate::process_group::detach_from_tty()?;
|
||||
#[cfg(target_os = "linux")]
|
||||
crate::process_group::set_parent_death_signal(parent_pid)?;
|
||||
crate::pty::make_inherited_fds_inheritable(&inherited_fds)?;
|
||||
crate::pty::close_inherited_fds_except(&inherited_fds);
|
||||
Ok(())
|
||||
});
|
||||
@@ -274,6 +275,27 @@ pub async fn spawn_process(
|
||||
spawn_process_with_stdin_mode(program, args, cwd, env, arg0, PipeStdinMode::Piped, &[]).await
|
||||
}
|
||||
|
||||
/// Spawn a process using regular pipes and preserve selected inherited file descriptors.
|
||||
pub async fn spawn_process_with_inherited_fds(
|
||||
program: &str,
|
||||
args: &[String],
|
||||
cwd: &Path,
|
||||
env: &HashMap<String, String>,
|
||||
arg0: &Option<String>,
|
||||
inherited_fds: &[i32],
|
||||
) -> Result<SpawnedProcess> {
|
||||
spawn_process_with_stdin_mode(
|
||||
program,
|
||||
args,
|
||||
cwd,
|
||||
env,
|
||||
arg0,
|
||||
PipeStdinMode::Piped,
|
||||
inherited_fds,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Spawn a process using regular pipes, but close stdin immediately.
|
||||
pub async fn spawn_process_no_stdin(
|
||||
program: &str,
|
||||
|
||||
@@ -345,6 +345,7 @@ async fn spawn_process_preserving_fds(
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
|
||||
make_inherited_fds_inheritable(&inherited_fds)?;
|
||||
close_inherited_fds_except(&inherited_fds);
|
||||
Ok(())
|
||||
});
|
||||
@@ -479,6 +480,28 @@ fn set_cloexec(fd: RawFd) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn make_inherited_fds_inheritable(inherited_fds: &[RawFd]) -> std::io::Result<()> {
|
||||
for inherited_fd in inherited_fds {
|
||||
clear_cloexec(*inherited_fd)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn clear_cloexec(fd: RawFd) -> std::io::Result<()> {
|
||||
let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) };
|
||||
if flags == -1 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
let result = unsafe { libc::fcntl(fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC) };
|
||||
if result == -1 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn close_inherited_fds_except(preserved_fds: &[RawFd]) {
|
||||
if let Ok(dir) = std::fs::read_dir("/dev/fd") {
|
||||
|
||||
@@ -833,6 +833,7 @@ async fn pty_spawn_can_preserve_inherited_fds() -> anyhow::Result<()> {
|
||||
|
||||
let mut read_end = unsafe { std::fs::File::from_raw_fd(fds[0]) };
|
||||
let write_end = unsafe { std::fs::File::from_raw_fd(fds[1]) };
|
||||
set_close_on_exec(write_end.as_raw_fd())?;
|
||||
|
||||
let mut env_map: HashMap<String, String> = std::env::vars().collect();
|
||||
env_map.insert(
|
||||
@@ -1071,6 +1072,7 @@ async fn pipe_spawn_no_stdin_can_preserve_inherited_fds() -> anyhow::Result<()>
|
||||
|
||||
let mut read_end = unsafe { std::fs::File::from_raw_fd(fds[0]) };
|
||||
let write_end = unsafe { std::fs::File::from_raw_fd(fds[1]) };
|
||||
set_close_on_exec(write_end.as_raw_fd())?;
|
||||
|
||||
let mut env_map: HashMap<String, String> = std::env::vars().collect();
|
||||
env_map.insert(
|
||||
@@ -1101,3 +1103,20 @@ async fn pipe_spawn_no_stdin_can_preserve_inherited_fds() -> anyhow::Result<()>
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn set_close_on_exec(fd: std::os::fd::RawFd) -> std::io::Result<()> {
|
||||
// SAFETY: `fd` comes from a live file descriptor owned by the current test.
|
||||
let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) };
|
||||
if flags == -1 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
|
||||
// SAFETY: `fd` comes from a live file descriptor owned by the current test.
|
||||
let result = unsafe { libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) };
|
||||
if result == -1 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user