mirror of
https://github.com/openai/codex.git
synced 2026-11-09 08:40:33 +00:00
Harden managed proxy listener handoff (#40999)
## Why Linux managed networking routed proxy connections through filesystem-backed Unix sockets, requiring an extra readable sandbox path and stale socket cleanup. ## What changed - Transfer loopback TCP listeners from the sandbox namespace to host bridge processes over anonymous Unix socket pairs. - Validate transferred descriptors, keep bootstrap channels private to each sandbox launch, and close them before running untrusted commands. - Tie proxy bridge lifetime to the sandbox so cancellation closes active upstream connections. ## Testing - Cover descriptor validation and interrupted handoffs. - Verify concurrent sandboxes keep proxy endpoints isolated and do not inherit privileged sockets. - Exercise managed networking end to end through `app-server`, confirming proxied access succeeds while direct host access remains blocked. GitOrigin-RevId: 6561e09c83b927c604951bf2aa00ff532d179da5
This commit is contained in:
1 parent
528fd7ace5
commit
f3741880f6
8 files changed
+839
-462
No files matched your search
@@ -1662,6 +1662,227 @@ allow_local_binding = false
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[tokio::test]
|
||||
async fn turn_start_enforces_managed_network_sandbox() -> Result<()> {
|
||||
skip_if_remote!(
|
||||
Ok(()),
|
||||
"network probe uses a host-local test executable and loopback server"
|
||||
);
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let codex_home = TempDir::new()?;
|
||||
MockResponsesConfig::new(&server.uri())
|
||||
.enable_feature(Feature::NetworkProxy)
|
||||
.enable_feature(Feature::UnifiedExec)
|
||||
.write(codex_home.path())?;
|
||||
std::fs::write(
|
||||
codex_home.path().join("requirements.toml"),
|
||||
r#"
|
||||
default_permissions = "managed-network"
|
||||
|
||||
[allowed_permission_profiles]
|
||||
managed-network = true
|
||||
":read-only" = true
|
||||
|
||||
[permissions.managed-network]
|
||||
extends = ":read-only"
|
||||
|
||||
[permissions.managed-network.network]
|
||||
enabled = true
|
||||
allow_local_binding = false
|
||||
allow_upstream_proxy = false
|
||||
|
||||
[permissions.managed-network.network.domains]
|
||||
"127.0.0.2" = "allow"
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let mut app_server = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.build_initialized()
|
||||
.await?;
|
||||
let ThreadStartResponse { thread, .. } = app_server
|
||||
.start_thread(ThreadStartParams {
|
||||
model: Some("mock-model".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
|
||||
// A different loopback address avoids confusing the host's target port with
|
||||
// a coincidentally equal 127.0.0.1 listener port inside the sandbox namespace.
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.2:0").await?;
|
||||
let target = listener.local_addr()?;
|
||||
let router = axum::Router::new().route(
|
||||
"/sandbox",
|
||||
axum::routing::get(|| async { "sandbox proxy response" }),
|
||||
);
|
||||
// Keep the real origin listening throughout both the proxied and direct
|
||||
// requests, and clean it up even if an assertion fails.
|
||||
let _origin = tokio_util::task::AbortOnDropHandle::new(tokio::spawn(async move {
|
||||
axum::serve(listener, router).await
|
||||
}));
|
||||
let test_executable = std::env::current_exe()?;
|
||||
let test_executable = test_executable
|
||||
.to_str()
|
||||
.context("integration test executable path must be UTF-8")?;
|
||||
// Libtest selectors omit the integration crate name.
|
||||
let (_, test_module) = module_path!()
|
||||
.split_once("::")
|
||||
.context("network sandbox test must be in an integration test module")?;
|
||||
let child_test = format!("{test_module}::managed_network_sandbox_probe");
|
||||
let target_env = format!("CODEX_TEST_MANAGED_NETWORK_TARGET={target}");
|
||||
let command = shlex::try_join([
|
||||
"env",
|
||||
&target_env,
|
||||
test_executable,
|
||||
"--exact",
|
||||
&child_test,
|
||||
"--ignored",
|
||||
"--nocapture",
|
||||
"--test-threads=1",
|
||||
])?;
|
||||
let call_id = "managed-network-probe";
|
||||
let probe_timeout = std::time::Duration::from_secs(/*secs*/ 30);
|
||||
let _command_response = responses::mount_sse_once(
|
||||
&server,
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-network-command"),
|
||||
responses::ev_exec_command_call_with_args(
|
||||
call_id,
|
||||
&json!({
|
||||
"cmd": command,
|
||||
"shell": "/bin/sh",
|
||||
"login": false,
|
||||
"yield_time_ms": probe_timeout.as_millis(),
|
||||
}),
|
||||
),
|
||||
responses::ev_completed("resp-network-command"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let final_response = responses::mount_sse_once(
|
||||
&server,
|
||||
create_final_assistant_message_sse_response("Network probe complete")?,
|
||||
)
|
||||
.await;
|
||||
|
||||
let _: TurnStartResponse = app_server
|
||||
.request(|request_id| ClientRequest::TurnStart {
|
||||
request_id,
|
||||
params: TurnStartParams {
|
||||
thread_id: thread.id,
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "Verify managed sandbox networking".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
permissions: Some("managed-network".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
|
||||
let expected_output = "proxied request succeeded; direct access blocked\n";
|
||||
timeout(probe_timeout, async {
|
||||
loop {
|
||||
let completed: ItemCompletedNotification =
|
||||
app_server.read_notification("item/completed").await?;
|
||||
if let ThreadItem::CommandExecution {
|
||||
id,
|
||||
status,
|
||||
exit_code,
|
||||
..
|
||||
} = completed.item
|
||||
&& id == call_id
|
||||
{
|
||||
assert_eq!(
|
||||
(status, exit_code),
|
||||
(CommandExecutionStatus::Completed, Some(0))
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
anyhow::Ok(())
|
||||
})
|
||||
.await??;
|
||||
let completed: TurnCompletedNotification = timeout(
|
||||
probe_timeout,
|
||||
app_server.read_notification("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(completed.turn.status, TurnStatus::Completed);
|
||||
// Early stdout can reach the tool-result buffer before the separate event
|
||||
// subscriber attaches, so verify it in the model continuation instead.
|
||||
// The marker also proves the child ran: libtest succeeds if no selector matches.
|
||||
let output = final_response
|
||||
.single_request()
|
||||
.function_call_output_text(call_id)
|
||||
.context("model continuation should contain the sandboxed command output")?;
|
||||
assert!(
|
||||
output.contains(expected_output),
|
||||
"unexpected tool output: {output}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Runs as the model-controlled command inside app-server's managed network sandbox.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
#[ignore = "invoked through app-server's managed network sandbox"]
|
||||
fn managed_network_sandbox_probe() -> Result<()> {
|
||||
use std::io::ErrorKind;
|
||||
use std::io::Read;
|
||||
use std::io::Write;
|
||||
use std::net::SocketAddr;
|
||||
use std::net::TcpStream;
|
||||
use std::time::Duration;
|
||||
|
||||
let target: SocketAddr = std::env::var("CODEX_TEST_MANAGED_NETWORK_TARGET")?.parse()?;
|
||||
let proxy = url::Url::parse(&std::env::var("HTTP_PROXY")?)?;
|
||||
let proxy_address = SocketAddr::new(
|
||||
proxy.host_str().context("missing proxy host")?.parse()?,
|
||||
proxy
|
||||
.port_or_known_default()
|
||||
.context("missing proxy port")?,
|
||||
);
|
||||
let proxy_timeout = Duration::from_secs(/*secs*/ 5);
|
||||
let mut connection = TcpStream::connect_timeout(&proxy_address, proxy_timeout)?;
|
||||
connection.set_read_timeout(Some(proxy_timeout))?;
|
||||
connection.set_write_timeout(Some(proxy_timeout))?;
|
||||
// HTTP/1.0 keeps this small fixture response close-delimited, without chunked encoding.
|
||||
write!(
|
||||
connection,
|
||||
"GET http://{target}/sandbox HTTP/1.0\r\nHost: {target}\r\nConnection: close\r\n\r\n"
|
||||
)?;
|
||||
let mut response = String::new();
|
||||
connection
|
||||
.take(/*limit*/ 4096)
|
||||
.read_to_string(&mut response)?;
|
||||
let (headers, body) = response
|
||||
.split_once("\r\n\r\n")
|
||||
.context("incomplete proxy HTTP response")?;
|
||||
let status = headers
|
||||
.split_whitespace()
|
||||
.nth(/*n*/ 1)
|
||||
.context("missing HTTP response status")?;
|
||||
assert_eq!((status, body), ("200", "sandbox proxy response"));
|
||||
|
||||
let direct_error = TcpStream::connect_timeout(&target, Duration::from_secs(/*secs*/ 2))
|
||||
.expect_err("sandbox allowed direct host access");
|
||||
assert!(
|
||||
matches!(
|
||||
direct_error.kind(),
|
||||
ErrorKind::ConnectionRefused
|
||||
| ErrorKind::NetworkUnreachable
|
||||
| ErrorKind::PermissionDenied
|
||||
),
|
||||
"unexpected direct connection error: {direct_error}"
|
||||
);
|
||||
println!("proxied request succeeded; direct access blocked");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_start_rejects_unknown_environment_before_starting_turn() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
|
||||
Reference in new issue
Block a user