From 8132e8d42df0bb2ae6a190bf0de53eb1dae203a5 Mon Sep 17 00:00:00 2001 From: Seongjae Date: Sat, 19 Sep 2026 17:03:52 +0900 Subject: [PATCH 1/2] Enforce Enabled network through an isolated netns and helper-owned proxy. Restricted stays fail-closed OS deny; Enabled without the Linux helper is PROCESS_SPAWN_FAILED rather than host FullAccess. Co-authored-by: Cursor --- crates/domain/src/execution.rs | 31 +++-- crates/linux-sandbox-protocol/src/lib.rs | 17 ++- crates/linux-sandbox/Cargo.lock | 2 + crates/linux-sandbox/Cargo.toml | 2 + crates/linux-sandbox/src/codex.rs | 9 +- crates/linux-sandbox/src/main.rs | 9 +- crates/linux-sandbox/src/prepare.rs | 44 +++++- crates/linux-sandbox/src/proxy.rs | 110 +++++++++++++++ crates/linux-sandbox/tests/isolation.rs | 167 ++++++++++++++++++++++- crates/policy/src/lib.rs | 23 ++++ crates/policy/src/permission.rs | 3 +- crates/runner/src/process.rs | 75 ++++++++-- crates/server/src/mcp.rs | 64 +++++++-- docs/codex-reuse.md | 23 ++-- docs/execution-substrate.md | 17 ++- docs/ko/codex-reuse.md | 23 ++-- docs/ko/execution-substrate.md | 17 ++- docs/ko/operations.md | 16 ++- docs/ko/runner-isolation.md | 9 +- docs/operations.md | 16 ++- docs/runner-isolation.md | 9 +- docs/translations.json | 16 +-- scripts/check-no-model-deps.sh | 5 +- 23 files changed, 597 insertions(+), 110 deletions(-) create mode 100644 crates/linux-sandbox/src/proxy.rs diff --git a/crates/domain/src/execution.rs b/crates/domain/src/execution.rs index ac14a14..0abdab9 100644 --- a/crates/domain/src/execution.rs +++ b/crates/domain/src/execution.rs @@ -218,14 +218,12 @@ impl WorkspaceExecutionInfo { } } - /// Overlay after a successful Linux helper probe. Restricted network - /// advertises OS enforcement; Enabled stays unenforced in this WP. - pub fn with_linux_command_sandbox(mut self, network_restricted: bool) -> Self { + /// Overlay after a successful Linux helper probe. Restricted deny and + /// Enabled managed-proxy both advertise OS enforcement. + pub fn with_linux_command_sandbox(mut self) -> Self { self.isolation.command_sandbox = CommandSandboxState::LinuxSandbox; - if network_restricted { - self.network.enforcement = NetworkEnforcementState::Enforced; - self.network.client_may_escalate = false; - } + self.network.enforcement = NetworkEnforcementState::Enforced; + self.network.client_may_escalate = false; self } } @@ -335,7 +333,7 @@ mod tests { exec: true, }, ) - .with_linux_command_sandbox(true); + .with_linux_command_sandbox(); assert_eq!( exec.isolation.command_sandbox, CommandSandboxState::LinuxSandbox @@ -347,6 +345,23 @@ mod tests { assert_eq!(json["network"]["enforcement"], "enforced"); } + #[test] + fn linux_sandbox_overlay_enforces_enabled_network() { + let exec = WorkspaceExecutionInfo::from_effective( + environment(ClientEnvironmentKind::Host, true, true, true), + EffectivePermissionInfo { + read: true, + write: true, + exec: true, + }, + NetworkPolicyState::Enabled, + ) + .with_linux_command_sandbox(); + assert_eq!(exec.network.policy, NetworkPolicyState::Enabled); + assert_eq!(exec.network.enforcement, NetworkEnforcementState::Enforced); + assert!(!exec.network.client_may_escalate); + } + #[test] fn host_read_only_denies_write_and_exec() { let exec = compose( diff --git a/crates/linux-sandbox-protocol/src/lib.rs b/crates/linux-sandbox-protocol/src/lib.rs index 9aa6794..ed94aa2 100644 --- a/crates/linux-sandbox-protocol/src/lib.rs +++ b/crates/linux-sandbox-protocol/src/lib.rs @@ -7,11 +7,13 @@ use serde::{Deserialize, Serialize}; /// JSON request/response version for helper `prepare`. Not UDS. pub const SANDBOX_HELPER_PROTOCOL: u32 = 1; -/// This work package only hard-denies network. `Enabled` / proxy is later. +/// Restricted is isolated netns + Restricted seccomp. Enabled is isolated +/// netns plus the helper-owned managed proxy (`--allow-network-for-proxy`). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum SandboxNetwork { Restricted, + Enabled, } /// Stdin JSON for `codespace-linux-sandbox prepare`. @@ -60,6 +62,17 @@ mod tests { serde_json::from_value::(json).unwrap(), req ); + + let enabled = SandboxPrepareRequest { + network: SandboxNetwork::Enabled, + ..req.clone() + }; + let json = serde_json::to_value(&enabled).unwrap(); + assert_eq!(json["network"], "enabled"); + assert_eq!( + serde_json::from_value::(json).unwrap(), + enabled + ); } #[test] @@ -87,7 +100,7 @@ mod tests { "workspace_root": "/", "command_cwd": "/", "writable_workspace": false, - "network": "enabled", + "network": "open", "argv": ["/bin/true"] }); assert!(serde_json::from_value::(json).is_err()); diff --git a/crates/linux-sandbox/Cargo.lock b/crates/linux-sandbox/Cargo.lock index e146c25..e61f2ae 100644 --- a/crates/linux-sandbox/Cargo.lock +++ b/crates/linux-sandbox/Cargo.lock @@ -611,6 +611,7 @@ version = "0.6.0" dependencies = [ "codespace-linux-sandbox-protocol", "codex-linux-sandbox", + "codex-network-proxy", "codex-protocol", "codex-sandboxing", "codex-utils-path-uri", @@ -621,6 +622,7 @@ dependencies = [ "serde", "serde_json", "tempfile", + "tokio", ] [[package]] diff --git a/crates/linux-sandbox/Cargo.toml b/crates/linux-sandbox/Cargo.toml index be0f220..a27596d 100644 --- a/crates/linux-sandbox/Cargo.toml +++ b/crates/linux-sandbox/Cargo.toml @@ -18,12 +18,14 @@ resolver = "2" [dependencies] codespace-linux-sandbox-protocol = { path = "../linux-sandbox-protocol" } codex-linux-sandbox = { path = "../../third_party/codex/codex-rs/linux-sandbox" } +codex-network-proxy = { path = "../../third_party/codex/codex-rs/network-proxy" } codex-protocol = { path = "../../third_party/codex/codex-rs/protocol" } codex-sandboxing = { path = "../../third_party/codex/codex-rs/sandboxing" } codex-utils-path-uri = { path = "../../third_party/codex/codex-rs/utils/path-uri" } libc = "0.2" serde = { version = "1", features = ["derive"] } serde_json = "1" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "io-util", "sync"] } # Resolver guards. Codex pin 6b9826e is validated against the Rama # 0.3.0-alpha.4 train. Leaf crates use ^0.3.0-alpha.4, so a fresh # resolve can pick stable 0.3.0 and break OpaqueError. These keys are diff --git a/crates/linux-sandbox/src/codex.rs b/crates/linux-sandbox/src/codex.rs index 5ed6f28..6de7723 100644 --- a/crates/linux-sandbox/src/codex.rs +++ b/crates/linux-sandbox/src/codex.rs @@ -1,7 +1,7 @@ //! Linux `codex_linux_sandbox::run_main`. Non-Linux compiles this module -//! but must not call the upstream entry (it panics). After `run --plan`, -//! this process `exec`s itself with Codex argv so the managed PID is -//! unchanged. +//! but must not call the upstream entry (it panics). Restricted +//! `run --plan` `exec`s this process with Codex argv so the managed PID +//! is unchanged. Enabled spawn+waits instead so `NetworkProxy` can live. use std::path::PathBuf; @@ -18,7 +18,8 @@ pub fn run_main() { } /// Replace this process with the same binary and `argv` (Codex flags). -/// Does not spawn an extra child. Inherits the env the runner applied. +/// Restricted `run --plan` uses this so the managed PID stays the helper. +/// Enabled starts `NetworkProxy` in the helper and spawn+waits instead. pub fn exec_self(argv: &[String]) -> ! { let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("codespace-linux-sandbox")); #[cfg(unix)] diff --git a/crates/linux-sandbox/src/main.rs b/crates/linux-sandbox/src/main.rs index fdcc654..e150946 100644 --- a/crates/linux-sandbox/src/main.rs +++ b/crates/linux-sandbox/src/main.rs @@ -6,6 +6,7 @@ mod codex; mod plan; mod prepare; mod probe; +mod proxy; fn main() { let mut args = std::env::args(); @@ -30,7 +31,13 @@ fn run_plan(args: &[String]) { } }; match plan::load_and_unlink(&plan_path) { - Ok(argv) => codex::exec_self(&argv), + Ok(argv) => { + if argv.iter().any(|arg| arg == "--allow-network-for-proxy") { + proxy::run_codex_with_proxy(&argv); + } else { + codex::exec_self(&argv); + } + } Err(message) => { eprintln!("{message}"); std::process::exit(1); diff --git a/crates/linux-sandbox/src/prepare.rs b/crates/linux-sandbox/src/prepare.rs index 632f2ad..279fd89 100644 --- a/crates/linux-sandbox/src/prepare.rs +++ b/crates/linux-sandbox/src/prepare.rs @@ -77,18 +77,27 @@ pub(crate) fn linux_sandbox_args( &profile, &policy_cwd, /*use_legacy_landlock*/ false, - /*allow_network_for_proxy*/ false, + matches!(req.network, SandboxNetwork::Enabled), ) })) .map_err(|_| "failed to build linux sandbox argv".to_string())?; if args.iter().any(|arg| { - arg == "--allow-network-for-proxy" - || arg == "--proxy-route-spec" + arg == "--proxy-route-spec" || arg == "--not-a-security-boundary" || arg == "--use-legacy-landlock" }) { return Err("linux sandbox argv included a forbidden helper flag".into()); } + let has_proxy_flag = args.iter().any(|arg| arg == "--allow-network-for-proxy"); + match req.network { + SandboxNetwork::Restricted if has_proxy_flag => { + return Err("restricted network plan must not enable the managed proxy".into()); + } + SandboxNetwork::Enabled if !has_proxy_flag => { + return Err("enabled network plan must include --allow-network-for-proxy".into()); + } + SandboxNetwork::Restricted | SandboxNetwork::Enabled => {} + } Ok(args) } @@ -136,6 +145,7 @@ fn permission_profile( let file_system = FileSystemSandboxPolicy::restricted(entries); let network = match req.network { SandboxNetwork::Restricted => NetworkSandboxPolicy::Restricted, + SandboxNetwork::Enabled => NetworkSandboxPolicy::Enabled, }; Ok(PermissionProfile::from_runtime_permissions( &file_system, @@ -168,12 +178,16 @@ mod tests { use serde_json::Value; fn req(root: &Path, writable: bool) -> SandboxPrepareRequest { + req_network(root, writable, SandboxNetwork::Restricted) + } + + fn req_network(root: &Path, writable: bool, network: SandboxNetwork) -> SandboxPrepareRequest { SandboxPrepareRequest { protocol: SANDBOX_HELPER_PROTOCOL, workspace_root: root.to_string_lossy().into_owned(), command_cwd: root.to_string_lossy().into_owned(), writable_workspace: writable, - network: SandboxNetwork::Restricted, + network, argv: vec!["/bin/echo".into(), "hi".into()], } } @@ -186,6 +200,10 @@ mod tests { linux_sandbox_args(&req(root, writable), &dummy_helper()).expect("prepare") } + fn launch_args_network(root: &Path, network: SandboxNetwork) -> Vec { + linux_sandbox_args(&req_network(root, true, network), &dummy_helper()).expect("prepare") + } + fn profile_json(args: &[String]) -> Value { let idx = args .iter() @@ -247,6 +265,24 @@ mod tests { ); } + #[test] + fn enabled_plan_has_allow_network_for_proxy_only() { + let dir = tempfile::tempdir().unwrap(); + let args = launch_args_network(dir.path(), SandboxNetwork::Enabled); + assert!( + args.iter().any(|arg| arg == "--allow-network-for-proxy"), + "Enabled must request the loopback proxy bridge: {args:?}" + ); + assert!( + !args.iter().any(|arg| arg == "--proxy-route-spec" + || arg == "--not-a-security-boundary" + || arg == "--use-legacy-landlock"), + "proxy route spec is attached at helper run time, not in the plan: {args:?}" + ); + let profile = profile_json(&args); + assert_eq!(profile["network"], "enabled"); + } + #[test] fn permission_profile_reads_helper_for_inner_reexec() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/linux-sandbox/src/proxy.rs b/crates/linux-sandbox/src/proxy.rs new file mode 100644 index 0000000..6b7af70 --- /dev/null +++ b/crates/linux-sandbox/src/proxy.rs @@ -0,0 +1,110 @@ +//! Host-side managed `NetworkProxy` for Enabled `run --plan`. +//! Restricted still `exec`s Codex argv in this process. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::process::Command; +use std::sync::Arc; + +use codex_network_proxy::{ + NetworkProxy, NetworkProxyConfig, NetworkProxyHandle, NetworkProxyState, + RemoteNetworkProxyConfig, RemoteNetworkProxyLaunchConfig, +}; + +pub fn run_codex_with_proxy(argv: &[String]) -> ! { + become_group_leader(); + let runtime = match tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(err) => { + eprintln!("failed to start network proxy runtime: {err}"); + std::process::exit(1); + } + }; + let (proxy, handle) = match runtime.block_on(start_proxy()) { + Ok(started) => started, + Err(message) => { + eprintln!("{message}"); + std::process::exit(1); + } + }; + let mut env: HashMap = std::env::vars().collect(); + proxy.apply_to_env(&mut env); + let status = match spawn_codex(argv, &env) { + Ok(mut child) => child.wait(), + Err(err) => { + let _ = runtime.block_on(handle.shutdown()); + eprintln!("failed to spawn linux sandbox helper: {err}"); + std::process::exit(1); + } + }; + let _ = runtime.block_on(handle.shutdown()); + drop(proxy); + match status { + Ok(status) => std::process::exit(status.code().unwrap_or(1)), + Err(err) => { + eprintln!("failed to wait for linux sandbox helper: {err}"); + std::process::exit(1); + } + } +} + +async fn start_proxy() -> Result<(NetworkProxy, NetworkProxyHandle), String> { + let mut config = NetworkProxyConfig { + enabled: true, + allow_local_binding: true, + ..NetworkProxyConfig::default() + }; + // Destination allowlists are out of scope; Enabled HTTP goes through this proxy. + config.set_allowed_domains(vec!["*".to_string()]); + let remote = RemoteNetworkProxyConfig::from_effective_config(&config) + .map_err(|err| format!("failed to build network proxy config: {err}"))?; + let state = + NetworkProxyState::from_remote_launch_config(RemoteNetworkProxyLaunchConfig::new(remote)) + .map_err(|err| format!("failed to build network proxy state: {err}"))?; + let proxy = NetworkProxy::builder() + .state(Arc::new(state)) + .build() + .await + .map_err(|err| format!("failed to bind network proxy: {err}"))?; + let handle = proxy + .run() + .await + .map_err(|err| format!("failed to start network proxy: {err}"))?; + Ok((proxy, handle)) +} + +fn spawn_codex( + argv: &[String], + env: &HashMap, +) -> std::io::Result { + let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("codespace-linux-sandbox")); + let mut cmd = Command::new(exe); + cmd.args(argv).env_clear().envs(env); + #[cfg(target_os = "linux")] + { + use std::os::unix::process::CommandExt; + unsafe { + cmd.pre_exec(|| { + if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) != 0 { + return Err(std::io::Error::last_os_error()); + } + if libc::getppid() == 1 { + libc::raise(libc::SIGKILL); + } + Ok(()) + }); + } + } + cmd.spawn() +} + +fn become_group_leader() { + #[cfg(unix)] + unsafe { + libc::setpgid(0, 0); + } +} diff --git a/crates/linux-sandbox/tests/isolation.rs b/crates/linux-sandbox/tests/isolation.rs index 4a9f6b3..fcc8b9c 100644 --- a/crates/linux-sandbox/tests/isolation.rs +++ b/crates/linux-sandbox/tests/isolation.rs @@ -41,12 +41,16 @@ fn sandbox_exec_env(home: &Path) -> BTreeMap { } fn prepare_plan(root: &Path, command: &[String]) -> PathBuf { + prepare_plan_network(root, command, "restricted") +} + +fn prepare_plan_network(root: &Path, command: &[String], network: &str) -> PathBuf { let request = serde_json::json!({ "protocol": 1, "workspace_root": root, "command_cwd": root, "writable_workspace": true, - "network": "restricted", + "network": network, "argv": command, }); let mut child = Command::new(helper_bin()) @@ -76,7 +80,15 @@ fn prepare_plan(root: &Path, command: &[String]) -> PathBuf { } fn launch(root: &Path, command: &[String]) -> (PathBuf, Vec, PathBuf) { - let plan = prepare_plan(root, command); + launch_network(root, command, "restricted") +} + +fn launch_network( + root: &Path, + command: &[String], + network: &str, +) -> (PathBuf, Vec, PathBuf) { + let plan = prepare_plan_network(root, command, network); ( helper_bin(), vec![ @@ -119,7 +131,11 @@ fn run_ok(root: &Path, command: &[String]) -> String { } fn run_status(root: &Path, command: &[String]) -> std::process::ExitStatus { - let (program, args, _) = launch(root, command); + run_status_network(root, command, "restricted") +} + +fn run_status_network(root: &Path, command: &[String], network: &str) -> std::process::ExitStatus { + let (program, args, _) = launch_network(root, command, network); Command::new(&program) .args(&args) .current_dir(root) @@ -132,6 +148,30 @@ fn run_status(root: &Path, command: &[String]) -> std::process::ExitStatus { .expect("spawn helper") } +fn run_ok_network(root: &Path, command: &[String], network: &str) -> String { + let (program, args, plan) = launch_network(root, command, network); + let output = Command::new(&program) + .args(&args) + .current_dir(root) + .env_clear() + .envs(sandbox_exec_env(root)) + .stdin(Stdio::null()) + .output() + .expect("spawn helper"); + assert!( + output.status.success(), + "status={} stderr={} stdout={}", + output.status, + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout) + ); + assert!( + !plan.exists(), + "run --plan must unlink the opaque plan file" + ); + String::from_utf8_lossy(&output.stdout).into_owned() +} + fn linux_ready() -> bool { let ready = cfg!(target_os = "linux") && probe_helper(); if require_linux_sandbox() { @@ -339,6 +379,94 @@ fn inet_socket_denied_unix_socket_allowed() { assert!(unix.success(), "AF_UNIX must be allowed"); } +#[test] +fn enabled_http_reaches_host_loopback_only_through_proxy() { + if !linux_ready() { + return; + } + let Some(python) = python3() else { + eprintln!("skip: python3 not present for proxy checks"); + return; + }; + let dir = tempfile::tempdir().unwrap(); + let (port, stop, server) = spawn_loopback_http(); + let target = format!("http://127.0.0.1:{port}/"); + + let direct = run_status_network( + dir.path(), + &[ + python.display().to_string(), + "-c".into(), + format!( + "import socket; s=socket.create_connection(('127.0.0.1', {port}), 2); s.close()" + ), + ], + "enabled", + ); + assert!( + !direct.success(), + "Enabled netns must not reach host loopback except via the managed proxy" + ); + + let proxied = run_ok_network( + dir.path(), + &[ + python.display().to_string(), + "-c".into(), + format!( + "import urllib.request; print(urllib.request.urlopen({target:?}, timeout=4).read().decode())" + ), + ], + "enabled", + ); + let _ = stop.send(()); + let _ = server.join(); + assert!( + proxied.contains("hello"), + "HTTP through the managed proxy must succeed, got {proxied:?}" + ); +} + +fn spawn_loopback_http() -> ( + u16, + std::sync::mpsc::Sender<()>, + std::thread::JoinHandle<()>, +) { + use std::io::{Read, Write}; + use std::net::TcpListener; + let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback http"); + listener.set_nonblocking(true).expect("nonblocking"); + let port = listener.local_addr().expect("local addr").port(); + let (tx, rx) = std::sync::mpsc::channel(); + let handle = std::thread::spawn(move || { + let start = Instant::now(); + loop { + if rx.try_recv().is_ok() || start.elapsed() > Duration::from_secs(12) { + break; + } + match listener.accept() { + Ok((mut stream, _)) => { + let _ = stream.set_nonblocking(false); + let mut buf = [0u8; 2048]; + let _ = stream.read(&mut buf); + let body = b"hello"; + let _ = write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + let _ = stream.write_all(body); + } + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(10)); + } + Err(_) => break, + } + } + }); + (port, tx, handle) +} + #[test] fn terminate_kills_sandbox_tree() { if !linux_ready() { @@ -371,6 +499,39 @@ fn terminate_kills_sandbox_tree() { } } +#[test] +fn terminate_kills_enabled_proxy_tree() { + if !linux_ready() { + return; + } + let dir = tempfile::tempdir().unwrap(); + let (program, args, _) = + launch_network(dir.path(), &["/bin/sleep".into(), "30".into()], "enabled"); + let mut child = Command::new(&program) + .args(&args) + .current_dir(dir.path()) + .env_clear() + .envs(sandbox_exec_env(dir.path())) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn sleep"); + std::thread::sleep(Duration::from_millis(150)); + child.kill().expect("kill helper"); + let start = Instant::now(); + loop { + if child.try_wait().ok().flatten().is_some() { + break; + } + assert!( + start.elapsed() < Duration::from_secs(5), + "Enabled proxy helper tree did not die with the helper" + ); + std::thread::sleep(Duration::from_millis(20)); + } +} + #[test] fn pty_isatty_and_stdin_roundtrip() { if !linux_ready() { diff --git a/crates/policy/src/lib.rs b/crates/policy/src/lib.rs index cc83824..6dfdedf 100644 --- a/crates/policy/src/lib.rs +++ b/crates/policy/src/lib.rs @@ -41,6 +41,9 @@ pub struct Workspace { pub environment_id: String, #[serde(default)] pub environment_kind: EnvironmentKind, + /// Operator JSON, like `environment`. Not an MCP tool field. + #[serde(default)] + pub network: NetworkAxis, } fn default_environment_id() -> String { @@ -55,6 +58,7 @@ impl Workspace { profile, environment_id: DEFAULT_ENVIRONMENT_ID.to_string(), environment_kind: EnvironmentKind::Host, + network: NetworkAxis::Restricted, } } @@ -97,6 +101,8 @@ struct FileWorkspace { profile: Profile, #[serde(default)] environment: Option, + #[serde(default)] + network: NetworkAxis, } impl Registry { @@ -168,6 +174,7 @@ impl Registry { profile: entry.profile, environment_id: environment.id.clone(), environment_kind: environment.kind, + network: entry.network, }); } Ok(registry) @@ -273,6 +280,7 @@ mod tests { assert_eq!(ws.profile, Profile::ReadOnly); assert_eq!(ws.environment_id, DEFAULT_ENVIRONMENT_ID); assert_eq!(ws.environment_kind, EnvironmentKind::Host); + assert_eq!(ws.network, NetworkAxis::Restricted); } #[test] @@ -352,6 +360,21 @@ mod tests { assert!(err.contains("unknown environment")); } + #[test] + fn operator_network_enabled_loads() { + let json = r#"{"workspaces":{"demo":{"root":"/tmp/demo","network":"enabled"}}}"#; + let registry = Registry::load_json(json).unwrap(); + let ws = registry.get("demo").unwrap(); + assert_eq!(ws.network, NetworkAxis::Enabled); + assert_eq!(ws.profile, Profile::ReadOnly); + } + + #[test] + fn unknown_network_fails_config_load() { + let json = r#"{"workspaces":{"demo":{"root":"/tmp/demo","network":"open"}}}"#; + assert!(Registry::load_json(json).is_err()); + } + #[test] fn linux_container_environment_loads_but_is_not_an_exec_path() { let json = r#"{ diff --git a/crates/policy/src/permission.rs b/crates/policy/src/permission.rs index 334d8c2..6a8080f 100644 --- a/crates/policy/src/permission.rs +++ b/crates/policy/src/permission.rs @@ -26,9 +26,10 @@ pub struct PathRule { } /// Network axis is recorded only. It is not an allow engine. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum NetworkAxis { + #[default] Restricted, Enabled, } diff --git a/crates/runner/src/process.rs b/crates/runner/src/process.rs index 69bd205..a295851 100644 --- a/crates/runner/src/process.rs +++ b/crates/runner/src/process.rs @@ -588,6 +588,12 @@ impl ExecLaunch { /// `helper run --plan`; Codex translation happens inside that process. fn exec_launch(ws: &Workspace, req: &RunnerExecRequest) -> Result { if !linux_sandbox::probe() { + if matches!(req.policy.network, NetworkAxis::Enabled) { + return Err(ErrorBody::new( + ErrorCode::ProcessSpawnFailed, + "Enabled network requires the Linux command sandbox helper", + )); + } return Ok(ExecLaunch { program: PathBuf::from(&req.argv[0]), args: req.argv.iter().skip(1).map(OsString::from).collect(), @@ -595,7 +601,7 @@ fn exec_launch(ws: &Workspace, req: &RunnerExecRequest) -> Result Result Result { +fn sandbox_network(network: NetworkAxis) -> SandboxNetwork { match network { - NetworkAxis::Restricted => Ok(SandboxNetwork::Restricted), - NetworkAxis::Enabled => Err(ErrorBody::new( - ErrorCode::ProcessSpawnFailed, - "linux command sandbox does not support Enabled network yet", - )), + NetworkAxis::Restricted => SandboxNetwork::Restricted, + NetworkAxis::Enabled => SandboxNetwork::Enabled, } } @@ -709,13 +712,59 @@ mod tests { assert!(!require_linux_sandbox()); } - #[test] - fn enabled_network_is_not_silently_restricted() { - let err = sandbox_network(NetworkAxis::Enabled).unwrap_err(); - assert_eq!(err.code, ErrorCode::ProcessSpawnFailed); + #[tokio::test] + async fn enabled_network_is_not_silently_restricted() { + let dir = tempdir().unwrap(); + let ws = workspace(dir.path()); + let process_id = ProcessId("proc-enabled-net".into()); + let mut req = RunnerExecRequest::for_host( + vec!["/bin/echo".into(), "ok".into()], + process_id.clone(), + Profile::WorkspaceWrite, + ); + req.policy.network = NetworkAxis::Enabled; + let runner = InProcessRunner::new(Arc::new(|_| {})); + if !crate::linux_sandbox_available() { + let err = runner.exec(&ws, req).await.unwrap_err(); + assert_eq!( + err.as_execution().map(|body| body.code), + Some(ErrorCode::ProcessSpawnFailed) + ); + let restricted = exec_launch( + &ws, + &RunnerExecRequest::for_host( + vec!["/bin/true".into()], + ProcessId("proc-restricted-fallback".into()), + Profile::WorkspaceWrite, + ), + ) + .unwrap(); + assert!(!restricted.sandboxed); + return; + } + runner.exec(&ws, req).await.unwrap(); + let mut chunk = String::new(); + for _ in 0..200 { + let result = runner + .read_process(RunnerReadProcess { + process_id: process_id.clone(), + cursor: 0, + }) + .await + .unwrap(); + chunk = result.chunk; + if result.eof || chunk.contains("ok") { + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + assert!( + chunk.contains("ok"), + "Enabled network must spawn through the helper, got {chunk:?}" + ); assert_eq!( - sandbox_network(NetworkAxis::Restricted).unwrap(), - SandboxNetwork::Restricted + sandbox_network(NetworkAxis::Enabled), + SandboxNetwork::Enabled ); } diff --git a/crates/server/src/mcp.rs b/crates/server/src/mcp.rs index 582a911..dae897f 100644 --- a/crates/server/src/mcp.rs +++ b/crates/server/src/mcp.rs @@ -71,7 +71,9 @@ workspace_info.execution.isolation.command_sandbox is linux-sandbox, \ exec_command is wrapped by the Linux helper. When it is none, host \ execution is not an OS command sandbox. Network policy is reported by \ workspace_info. OS network enforcement follows \ -execution.network.enforcement; absence of enforcement is not permission. +execution.network.enforcement; absence of enforcement is not permission. \ +Enabled network uses a managed proxy; a missing Linux helper is not \ +permission. Treat apply_patch status=unknown as possibly executed. Do not blindly retry \ the mutation with a new operation_key. @@ -230,7 +232,7 @@ impl CodeSpace { .mark_shell_busy(¶ms.workspace_id.0, &process_id.0) .map_err(err_json)?; let mut req = RunnerExecRequest::for_host(params.command, process_id.clone(), ws.profile); - req.policy.network = PermissionProfile::from_workspace_profile(ws.profile).network; + req.policy.network = ws.network; req.tty = params.tty; match self.runner.exec(ws, req).await { Ok(result) => Ok(Json(ExecCommandResult { @@ -497,7 +499,8 @@ fn client_network_policy(axis: NetworkAxis) -> NetworkPolicyState { } fn workspace_execution_info(ws: &Workspace) -> WorkspaceExecutionInfo { - let policy = PermissionProfile::from_workspace_profile(ws.profile); + let mut policy = PermissionProfile::from_workspace_profile(ws.profile); + policy.network = ws.network; let permissions = EffectivePermissionInfo { read: policy.allows(Action::Read), write: policy.allows(Action::Write), @@ -515,15 +518,12 @@ fn workspace_execution_info(ws: &Workspace) -> WorkspaceExecutionInfo { permissions, client_network_policy(policy.network), ); - advertise_linux_sandbox(info, policy.network) + advertise_linux_sandbox(info) } -fn advertise_linux_sandbox( - info: WorkspaceExecutionInfo, - network: NetworkAxis, -) -> WorkspaceExecutionInfo { +fn advertise_linux_sandbox(info: WorkspaceExecutionInfo) -> WorkspaceExecutionInfo { if linux_sandbox_available() { - info.with_linux_command_sandbox(matches!(network, NetworkAxis::Restricted)) + info.with_linux_command_sandbox() } else { info } @@ -614,11 +614,13 @@ mod tests { } fn assert_advertised_matches_policy(ws: &Workspace, exec: &WorkspaceExecutionInfo) { - let policy = PermissionProfile::from_workspace_profile(ws.profile); + let mut policy = PermissionProfile::from_workspace_profile(ws.profile); + policy.network = ws.network; assert_eq!(exec.permissions.read, policy.allows(Action::Read)); assert_eq!(exec.permissions.write, policy.allows(Action::Write)); assert_eq!(exec.permissions.exec, policy.allows(Action::Exec)); assert_eq!(exec.network.policy, client_network_policy(policy.network)); + assert!(!exec.network.client_may_escalate); assert_eq!( exec.environment.exec_supported, ws.environment_kind.exec_supported() @@ -652,9 +654,7 @@ mod tests { exec.isolation.command_sandbox, CommandSandboxState::LinuxSandbox ); - if exec.network.policy == NetworkPolicyState::Restricted { - assert_eq!(exec.network.enforcement, NetworkEnforcementState::Enforced); - } + assert_eq!(exec.network.enforcement, NetworkEnforcementState::Enforced); } else { assert_eq!(exec.isolation.command_sandbox, CommandSandboxState::None); assert_eq!(exec.network.enforcement, NetworkEnforcementState::None); @@ -687,6 +687,14 @@ mod tests { text.contains("absence of enforcement is not permission"), "{text}" ); + assert!( + text.contains("Enabled network uses a managed proxy"), + "{text}" + ); + assert!( + text.contains("missing Linux helper is not permission"), + "{text}" + ); assert!(!text.contains("not granted by policy"), "{text}"); assert!( text.contains("Treat apply_patch status=unknown as possibly executed"), @@ -748,6 +756,36 @@ mod tests { assert!(!json.to_string().contains("\"environment_id\"")); } + #[test] + fn workspace_info_operator_enabled_network() { + let dir = tempfile::tempdir().unwrap(); + let json = serde_json::json!({ + "workspaces": { + "demo": { + "root": dir.path(), + "profile": "workspace-write", + "network": "enabled" + } + } + }); + let registry = Registry::load_json(&json.to_string()).unwrap(); + let info = lookup(®istry, Some("demo".into())).unwrap(); + let exec = info.execution.as_ref().expect("execution"); + assert_advertised_matches_policy(registry.get("demo").unwrap(), exec); + assert_eq!(exec.network.policy, NetworkPolicyState::Enabled); + assert!(!exec.network.client_may_escalate); + assert!(exec.permissions.write && exec.permissions.exec); + if linux_sandbox_available() { + assert_eq!(exec.network.enforcement, NetworkEnforcementState::Enforced); + assert_eq!( + exec.isolation.command_sandbox, + CommandSandboxState::LinuxSandbox + ); + } else { + assert_eq!(exec.network.enforcement, NetworkEnforcementState::None); + } + } + #[test] fn workspace_info_host_read_only_denies_write_and_exec() { let dir = tempfile::tempdir().unwrap(); diff --git a/docs/codex-reuse.md b/docs/codex-reuse.md index 7cbf144..770b185 100644 --- a/docs/codex-reuse.md +++ b/docs/codex-reuse.md @@ -236,8 +236,8 @@ Documented order. **Taken in code this WP:** process-hardening, UDS, PTY (`crates/pty` → `codex-utils-pty`), filesystem (`crates/file-system` → `LOCAL_FS` / `ExecutorFileSystem`), and linux-sandbox (`crates/linux-sandbox` binary → prepare / opaque plan / `run --plan` -exec, Restricted hard deny). **Not -taken:** network (`Enabled` + proxy). +Restricted `exec` / Enabled managed proxy). **Taken:** +network (`Enabled` + proxy). ```text process-hardening → PTY → UDS / path → filesystem → linux-sandbox → network @@ -285,18 +285,21 @@ and typed errors (`SymlinkRejected`, `NotRegularFile`). MCP `read` / Process boundary, not a library adapter. The runner sends `SandboxPrepareRequest` JSON (`SANDBOX_HELPER_PROTOCOL = 1`) to `prepare`, gets a plan **pathname** only, then spawns managed -`run --plan`. The helper unlinks the 0600 plan and `exec`s itself with -Codex argv (same PID). `WIRE_PROTOCOL` stays `3`. Restricted network is -`--unshare-net` plus Restricted seccomp. Direct proxy flags -(`--allow-network-for-proxy`, `--proxy-route-spec`) are unused; that is -the next WP. Runtime deps do not include `codex-core`; **dev-dependencies +`run --plan`. Restricted unlinks the 0600 plan and `exec`s itself with +Codex argv (same PID). Enabled starts `NetworkProxy` in the helper, sets +`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`, then spawn+waits the Codex +argv child (`--allow-network-for-proxy`; `--proxy-route-spec` is attached +by the Codex outer at runtime, not stored in the plan). `WIRE_PROTOCOL` +stays `3`. Restricted network is `--unshare-net` plus Restricted +seccomp. Enabled is isolated netns plus that managed proxy, not Codex +FullAccess host network. Runtime deps do not include `codex-core`; **dev-dependencies do** — adapter tests must not pull that graph into the product binary. `codex_protocol::PermissionProfile` stays inside the helper. `codespace-runner` depends on `codespace-linux-sandbox-protocol` only. -**Transitive (allowed in the adapter):** `codex-sandboxing`, -`codex-network-proxy`, `codex-protocol`. Direct use of the proxy is for -when a PermissionProfile **network** axis exists. Not an allow engine. +**Direct in the helper:** `codex-network-proxy` (`NetworkProxy` lifetime +for Enabled `run --plan`). **Transitive (allowed in the adapter):** +`codex-sandboxing`, `codex-protocol`. Not an allow engine. Not a root-workspace dep. ### Prefer reuse (when that WP) diff --git a/docs/execution-substrate.md b/docs/execution-substrate.md index 9a990f7..0f46f51 100644 --- a/docs/execution-substrate.md +++ b/docs/execution-substrate.md @@ -67,7 +67,12 @@ Exec axis (`read-only` denies, `workspace-write` allows). Path globs are **domain only**; live enforcement stays coarse `allow(Write|Exec)` plus PathSandbox. Restricted network is OS-enforced when the Linux helper probe succeeds (`workspace_info.execution.network.enforcement=enforced`). -`Enabled` / proxy is later. The axis never grants. This is not an +`Enabled` is isolated netns plus a helper-owned managed proxy, not host +FullAccess. Without the helper, Enabled is `PROCESS_SPAWN_FAILED` (not +silent allow). Operators may set workspace JSON `network` to +`restricted` (default) or `enabled`; MCP profiles stay +`read-only` / `workspace-write` and `{ "network": true }` is not a +grant. The axis never grants. This is not an import of Codex user config. ## Four axes (target domain) @@ -315,8 +320,8 @@ runtime **shape** on Runner DTOs (done), PermissionProfile domain in tool arg) (done), resource serializer (done), transport (`UdsRunner`) with process-hardening + UDS (done, opt-in), PTY I/O backend (done), filesystem mechanics under PathSandbox (done), Linux -command sandbox (done: helper process boundary, Restricted hard deny). Still -out: network (`Enabled` + proxy). +command sandbox (done: helper process boundary, Restricted hard deny, +Enabled managed proxy). P0 execution subgraph is complete. **P1** — operation state machine / diff ledger, approval fallback tools, internal watch, richer process handles (resize, caps), @@ -327,7 +332,7 @@ MCP contract, deterministic hooks, skills as resources or prompts. **P3** — remote environment, MCP federation, artifact registry. -The next **code** WPs are remaining execution subgraph crates behind -the existing trait, without splitting `apply_patch` into gateway RPCs. -Start at network. Sandbox / network are not a default homegrown OS +The next **code** WPs are P1 (operation state machine / diff ledger) +behind the existing trait, without splitting `apply_patch` into gateway RPCs. +Sandbox / network are not a default homegrown OS stack ([codex-reuse.md](codex-reuse.md)). diff --git a/docs/ko/codex-reuse.md b/docs/ko/codex-reuse.md index cde17cd..601024c 100644 --- a/docs/ko/codex-reuse.md +++ b/docs/ko/codex-reuse.md @@ -237,8 +237,8 @@ login, models, plugins, rollout도 따라옵니다. 그 폭발 반경은 여전 PTY(`crates/pty` → `codex-utils-pty`), filesystem(`crates/file-system` → `LOCAL_FS` / `ExecutorFileSystem`), linux-sandbox (`crates/linux-sandbox` 바이너리 → prepare / opaque plan / `run --plan` -exec, Restricted hard deny). **아직 안 -가져옴:** network(`Enabled` + proxy). +Restricted `exec` / Enabled 관리 프록시). **가져옴:** +network(`Enabled` + proxy). ```text process-hardening → PTY → UDS / path → filesystem → linux-sandbox → network @@ -286,19 +286,22 @@ open/read/write/remove/walk와 typed error(`SymlinkRejected`, 라이브러리 어댑터가 아니라 **프로세스 경계**입니다. 러너는 `SandboxPrepareRequest` JSON(`SANDBOX_HELPER_PROTOCOL = 1`)을 `prepare`에 보내고 plan **경로만** 받은 뒤 managed `run --plan`을 -spawn합니다. 헬퍼는 0600 plan을 unlink하고 같은 PID에서 Codex argv로 -`exec`합니다. `WIRE_PROTOCOL`은 `3`으로 남습니다. Restricted -네트워크는 `--unshare-net`과 Restricted seccomp입니다. 직접 -프록시 플래그(`--allow-network-for-proxy`, `--proxy-route-spec`)는 -쓰지 않습니다. 그건 다음 WP입니다. 런타임 의존성에는 `codex-core`가 +spawn합니다. Restricted는 0600 plan을 unlink하고 같은 PID에서 Codex argv로 +`exec`합니다. Enabled는 헬퍼가 `NetworkProxy`를 띄우고 +`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`를 채운 뒤 Codex argv 자식을 +spawn+wait합니다(`--allow-network-for-proxy`; `--proxy-route-spec`은 +plan에 넣지 않고 Codex outer가 런타임에 붙입니다). `WIRE_PROTOCOL`은 +`3`으로 남습니다. Restricted 네트워크는 `--unshare-net`과 Restricted +seccomp입니다. Enabled는 Codex FullAccess 호스트 네트워크가 아니라 +격리 netns + 그 관리 프록시입니다. 런타임 의존성에는 `codex-core`가 없습니다. **dev-dependencies에는 있습니다** — 어댑터 시험이 그 그래프를 제품 바이너리로 끌어오면 안 됩니다. `codex_protocol::PermissionProfile`은 헬퍼 안에 남습니다. `codespace-runner`는 `codespace-linux-sandbox-protocol`만 의존합니다. -**전이(어댑터에서 허용):** `codex-sandboxing`, `codex-network-proxy`, -`codex-protocol`. 프록시의 직접 사용은 PermissionProfile **네트워크** -축이 있을 때입니다. 허용 엔진이 아닙니다. 루트 워크스페이스 의존성이 +**헬퍼에서 직접:** `codex-network-proxy`(Enabled `run --plan`의 +`NetworkProxy` 수명). **전이(어댑터에서 허용):** `codex-sandboxing`, +`codex-protocol`. 허용 엔진이 아닙니다. 루트 워크스페이스 의존성이 아닙니다. ### 재사용 선호 (그 WP가 올 때) diff --git a/docs/ko/execution-substrate.md b/docs/ko/execution-substrate.md index 836dd9a..cf11260 100644 --- a/docs/ko/execution-substrate.md +++ b/docs/ko/execution-substrate.md @@ -67,9 +67,13 @@ sandbox-specific 그래프 edge도 검사합니다. (`read-only`는 거부, `workspace-write`는 허용). 경로 glob은 **표현만** 있고 live enforcement는 기존 coarse `allow(Write|Exec)` + PathSandbox입니다. Restricted 네트워크는 Linux 헬퍼 probe가 성공하면 OS에서 강제합니다 -(`workspace_info.execution.network.enforcement=enforced`). `Enabled` / -proxy는 이후입니다. 축이 허용을 올리지는 않습니다. Codex 사용자 설정을 -가져오는 것이 아닙니다. +(`workspace_info.execution.network.enforcement=enforced`). `Enabled`는 +호스트 FullAccess가 아니라 격리 netns + 헬퍼 소유 관리 프록시입니다. +헬퍼가 없으면 Enabled는 `PROCESS_SPAWN_FAILED`이며 조용한 허용이 +아닙니다. 운영자 워크스페이스 JSON `network`는 `restricted`(기본) 또는 +`enabled`입니다. MCP 프로필은 `read-only` / `workspace-write`로 남고 +`{ "network": true }`는 부여가 아닙니다. 축이 허용을 올리지는 않습니다. +Codex 사용자 설정을 가져오는 것이 아닙니다. ## 네 축 (목표 도메인) @@ -308,7 +312,7 @@ PermissionProfile 도메인(완료), Environment 도메인(운영자 등록, 도 인자 아님)(완료), 자원 직렬화기(완료), process-hardening + UDS를 받는 전송(`UdsRunner`)(완료, 선택적), PTY I/O 백엔드(완료), PathSandbox 아래 파일시스템 역학(완료), Linux command sandbox(완료: 헬퍼 프로세스 경계, Restricted -hard deny). 아직 밖: network(`Enabled` + proxy). +hard deny, Enabled 관리 프록시). P0 실행 서브그래프는 완료입니다. **P1** — 작업 상태 기계 / diff 원장, 승인 폴백 도구, 내부 watch, 더 풍부한 프로세스 핸들(resize, caps), 연결 끊김 정책. @@ -318,7 +322,6 @@ hard deny). 아직 밖: network(`Enabled` + proxy). **P3** — 원격 환경, MCP 연합, 아티팩트 레지스트리. -다음 **코드** WP는 기존 트레이트 뒤의 남은 실행 서브그래프이며, -`apply_patch`를 게이트웨이 RPC로 쪼개지 않습니다. network부터 -시작합니다. Sandbox / network는 기본 자체 OS 스택이 아닙니다 +다음 **코드** WP는 기존 트레이트 뒤의 P1(작업 상태 기계 / diff 원장)이며, +`apply_patch`를 게이트웨이 RPC로 쪼개지 않습니다. Sandbox / network는 기본 자체 OS 스택이 아닙니다 ([codex-reuse.md](codex-reuse.md)). diff --git a/docs/ko/operations.md b/docs/ko/operations.md index ad868f0..58b8236 100644 --- a/docs/ko/operations.md +++ b/docs/ko/operations.md @@ -57,7 +57,9 @@ spawn입니다. Linux에서 `CODESPACE_LINUX_SANDBOX_BIN`(또는 게이트웨이 다음 `helper run --plan`입니다. Codex argv는 러너에 들어오지 않습니다. `workspace_info.execution.isolation.command_sandbox`는 그때만 `linux-sandbox`이고, 아니면 `none`입니다. Restricted 네트워크는 그때 -OS에서 강제됩니다(`network.enforcement=enforced`). +OS에서 강제됩니다(`network.enforcement=enforced`). Enabled도 같은 +enforcement이며 헬퍼 안의 관리 HTTP 프록시를 씁니다. 헬퍼가 없으면 +`PROCESS_SPAWN_FAILED`이지 호스트 FullAccess가 아닙니다. `exec_command.tty` 기본값은 false(파이프)입니다. `tty: true`는 24x80 PTY를 붙입니다. Exec DTO cwd는 `WorkspaceRoot`이며 `PATH` / `HOME` / `LANG`은 러너 프로세스에서 적용합니다(PTY일 때 @@ -74,14 +76,18 @@ OS에서 강제됩니다(`network.enforcement=enforced`). "workspaces": { "demo": { "root": "/absolute/path/to/your/project", - "profile": "workspace-write" + "profile": "workspace-write", + "network": "restricted" } } } ``` 프로필: `read-only`(기본 의도) 또는 `workspace-write`. `host-admin`은 -제품 프로필이 아닙니다. 선택적 운영자 `environments`는 `host` 또는 +제품 프로필이 아닙니다. 선택적 운영자 `network`는 `restricted`(기본) +또는 `enabled`이며 `environment`처럼 워크스페이스 JSON이지 도구 인자나 +`{ "network": true }`가 아닙니다. Enabled는 Linux 헬퍼가 필요합니다. +선택적 운영자 `environments`는 `host` 또는 `linux-container`를 등록할 수 있습니다. 생략하면 암시적 로컬 호스트입니다. `linux-container`는 exec 경로가 아닙니다. 도구와 `workspace_info`에는 `environment_id`가 없습니다. `workspace_id`로 `workspace_info`를 호출하면 @@ -91,8 +97,8 @@ occupancy 아님 — `exec_command`나 `apply_patch`는 여전히 `WORKSPACE_BUSY`일 수 있음; 도구 존재는 `tools_exposed`), process가 가능할 때 resize 없는 고정 24x80 PTY, mutation lease / `WORKSPACE_BUSY`, 워크스페이스 범위 파일 도구 대 광고된 Linux command -sandbox, 헬퍼 probe가 성공하면 OS가 강제하는 restricted 네트워크 -정책입니다. +sandbox, 헬퍼 probe가 성공하면 OS가 강제하는 `network.policy` +(`restricted` 또는 `enabled`)입니다. `output_combined=true`는 `read_process`가 하나의 combined stream만 노출하고 stdout/stderr origin을 보존하지 않는다는 뜻입니다. `exec_command`는 `dispatch_status`(`confirmed` 또는 diff --git a/docs/ko/runner-isolation.md b/docs/ko/runner-isolation.md index d85f202..d7dea8d 100644 --- a/docs/ko/runner-isolation.md +++ b/docs/ko/runner-isolation.md @@ -7,9 +7,12 @@ 같은 `codespace-linux-sandbox run --plan` argv를 씁니다(bubblewrap + `no_new_privs`/seccomp; Codex 변환은 그 프로세스 안). prepare / protocol / helper OS spawn 실패는 `PROCESS_SPAWN_FAILED`입니다. -managed helper가 spawn된 뒤 `run --plan` load, self-exec, inner -sandbox 실패는 managed process exit입니다. probe가 실패하면(macOS, bwrap 없음) 샌드박스 +managed helper가 spawn된 뒤 `run --plan` load, Restricted self-exec, +Enabled 프록시 spawn, inner sandbox 실패는 managed process exit입니다. +probe가 실패하면(macOS, bwrap 없음) Restricted는 샌드박스 없이 실행하고 `workspace_info`는 `none`을 광고합니다. +Enabled는 헬퍼 없이 `PROCESS_SPAWN_FAILED`이며 호스트 네트워크가 +아닙니다. 게이트웨이 단위 시험은 macOS에서 실행할 수 있습니다. 그것은 개발 노트북에서 Linux 격리를 검증했다는 주장이 아닙니다. @@ -81,7 +84,7 @@ network (filesystem은 `crates/file-system`, linux-sandbox는 `crates/linux-sandbox` 바이너리와 `crates/linux-sandbox-protocol`). `codex-linux-sandbox`는 컨테이너 옆에 둘 수 있습니다. 그 `codex-core` **dev-dep**는 제품 그래프에서 빼 두세요. -다음 WP는 network(`Enabled` + proxy)입니다. `codex-exec`는 +P0 network(`Enabled` + 관리 프록시)는 가져왔습니다. `codex-exec`는 거절된 채로 남습니다. `codex-exec-server`는 참고 / 이후 백엔드이며 영구 거절은 아닙니다. 게이트웨이 정책이 유일한 허용 경로입니다. compose 픽스처에 호스트 Docker 소켓이나 이후 제어 소켓을 실수로 마운트하지 diff --git a/docs/operations.md b/docs/operations.md index d705f92..1e14fa9 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -58,7 +58,9 @@ gateway) probes successfully, that spawn is `helper run --plan` after a short `prepare`. Codex argv never enters the runner. `workspace_info.execution.isolation.command_sandbox` is `linux-sandbox` only then; otherwise `none`. Restricted network is OS-enforced in that -case (`network.enforcement=enforced`). `exec_command.tty` defaults to false (pipes). +case (`network.enforcement=enforced`). Enabled is the same enforcement +state with a managed HTTP proxy inside the helper; without the helper +it is `PROCESS_SPAWN_FAILED`, not host FullAccess. `exec_command.tty` defaults to false (pipes). `tty: true` attaches a PTY at 24x80. Exec DTO cwd is `WorkspaceRoot`; `PATH` / `HOME` / `LANG` are applied inside the runner process (`TERM=xterm` for PTY). @@ -72,14 +74,18 @@ at a **real directory you registered**. Models cannot add workspaces. "workspaces": { "demo": { "root": "/absolute/path/to/your/project", - "profile": "workspace-write" + "profile": "workspace-write", + "network": "restricted" } } } ``` Profiles: `read-only` (default intent) or `workspace-write`. `host-admin` -is not a product profile. Optional operator `environments` may register +is not a product profile. Optional operator `network` is `restricted` +(default) or `enabled` — this is workspace JSON like `environment`, not +a tool argument and not `{ "network": true }`. Enabled requires the +Linux helper. Optional operator `environments` may register `host` or `linux-container`. Omitted environment is implicit local host. `linux-container` is not an exec path. Tools and `workspace_info` have no `environment_id`. Call `workspace_info` with a `workspace_id` to read @@ -89,8 +95,8 @@ support only; not occupancy — `exec_command` or `apply_patch` may still return `WORKSPACE_BUSY`; tool existence is `tools_exposed`), fixed 24x80 PTY without resize when a process is available, mutation lease / `WORKSPACE_BUSY`, workspace-scoped file tools vs Linux command sandbox -when advertised, and restricted network policy with OS enforcement when -the helper probe succeeds. `output_combined=true` means `read_process` exposes one +when advertised, and `network.policy` (`restricted` or `enabled`) with +OS enforcement when the helper probe succeeds. `output_combined=true` means `read_process` exposes one combined stream; stdout/stderr identity is not preserved. `exec_command` returns `dispatch_status` (`confirmed` or `unknown`). Treat `unknown` patch/exec as possibly diff --git a/docs/runner-isolation.md b/docs/runner-isolation.md index 218bdd3..bc3585a 100644 --- a/docs/runner-isolation.md +++ b/docs/runner-isolation.md @@ -8,9 +8,10 @@ pipe and PTY spawn the same `codespace-linux-sandbox run --plan` argv (bubblewrap + `no_new_privs`/seccomp; Codex translation stays inside that process). Prepare / protocol / helper OS-spawn failure is `PROCESS_SPAWN_FAILED`. After the managed helper process is spawned, -`run --plan` load, self-exec, or inner sandbox failure is a managed -process exit. When the probe fails (macOS, no -bwrap), spawn is unsandboxed and `workspace_info` advertises `none`. +`run --plan` load, Restricted self-exec, Enabled proxy spawn, or inner +sandbox failure is a managed process exit. When the probe fails (macOS, no +bwrap), Restricted spawn is unsandboxed and `workspace_info` advertises `none`. +Enabled without a helper is `PROCESS_SPAWN_FAILED` (not host network). Gateway unit tests may run on macOS. That is not a claim that Linux isolation was verified on the development laptop. @@ -82,7 +83,7 @@ network (filesystem is taken via `crates/file-system`; linux-sandbox via the `crates/linux-sandbox` binary and `crates/linux-sandbox-protocol`). `codex-linux-sandbox` can sit beside a container; keep its `codex-core` **dev-dep** out of the product graph. -The next WP is network (`Enabled` + proxy). `codex-exec` stays +P0 network (`Enabled` + managed proxy) is taken. `codex-exec` stays rejected. `codex-exec-server` is a reference / future backend, not a forever reject. Gateway policy remains the only allow path. Do not mount a host Docker socket or a future control socket on the compose diff --git a/docs/translations.json b/docs/translations.json index c7945ab..c7de338 100644 --- a/docs/translations.json +++ b/docs/translations.json @@ -91,8 +91,8 @@ "워크스페이스-레지스트리", "이-문서가-검증하지-않는-것" ], - "source_sha256": "6d1fac0a089fe40ed1e8bcea563cb860452c5551d038379e50caa0d5295a6d62", - "translation_sha256": "c6650e783e2ab7b98039c21599773cf27b9d15a5b101892d34f07ec52cb2ceaa" + "source_sha256": "c296a1052686a33c4ef7e5e22696c62d4ba58adf622833b61db4375e811faa82", + "translation_sha256": "bbc4baab8e867b940f837982056c7ddfff667c8901289665b807c4dc31b0a234" }, { "id": "chatgpt-connector", @@ -179,8 +179,8 @@ "실행-기반", "훅과-스킬" ], - "source_sha256": "6757811d90483be000c2256e830d342b7404f652bf40f72c2803c5ad77212a0d", - "translation_sha256": "8cd2af2c4e0479dacc1b3f9acdcfdbb6ba7f6374b79a5d6bca7b9de1a411af69" + "source_sha256": "42159d3e4611f04f9cf4743892f7eb089226d9350163152c9386bf82f49908cb", + "translation_sha256": "f8e9397b4b24125ed69061288f0b9de843986a807f1e1e229c767bde5fb08748" }, { "id": "protocol-compatibility", @@ -274,8 +274,8 @@ "러너-격리", "이후-프로세스-분리" ], - "source_sha256": "add1bfda569c8a4964ee22d1000b6e39802023e69967d7436a6d333cb4a69a70", - "translation_sha256": "423a9a798d00d71eb05075e5549d010dd6f43a225b4a9c5408b4600909651da8" + "source_sha256": "feaebeb6072559c48dc356f3810c0563303d4e5b89013d3ac2930613062a8ac4", + "translation_sha256": "b4f54f498e03852ea2a46c812988397bb72679fe68ab91220cdf72c03e0c2cfd" }, { "id": "error-codes", @@ -342,8 +342,8 @@ "핀-6b9826e의-후보", "핵심-대-어댑터" ], - "source_sha256": "85c3e5efaa2dcc4c540ee9aa5ca911cdaad8bbe08e914aabd3a8c3357c3ae4c5", - "translation_sha256": "5ca737d630bcfcb6e5089307da53d495865852de597bf9352c54df33e16f6832" + "source_sha256": "d22a450bcaa323390adc8a49ccbe99f8876e3fc73dab9e134838d45fa05bc7e6", + "translation_sha256": "4cca24552254c99cb127614837fb9963083c9a289363317601929aad4a9648de" }, { "id": "upstream-lock", diff --git a/scripts/check-no-model-deps.sh b/scripts/check-no-model-deps.sh index c6fc87b..ff30669 100755 --- a/scripts/check-no-model-deps.sh +++ b/scripts/check-no-model-deps.sh @@ -71,11 +71,10 @@ fs_key_allowed() { esac } -# crates/linux-sandbox: helper argv + bwrap/seccomp. Direct keys only. -# Transitive: codex-network-proxy (no direct proxy API). +# crates/linux-sandbox: helper argv + bwrap/seccomp + Enabled NetworkProxy. linux_sandbox_key_allowed() { case "$1" in - codex-linux-sandbox|codex-sandboxing|codex-protocol|codex-utils-path-uri) return 0 ;; + codex-linux-sandbox|codex-sandboxing|codex-protocol|codex-utils-path-uri|codex-network-proxy) return 0 ;; *) return 1 ;; esac } From 28a4ec5ddbb665470923f72dc7216e42232cd25c Mon Sep 17 00:00:00 2001 From: Seongjae Date: Sat, 19 Sep 2026 17:33:32 +0900 Subject: [PATCH 2/2] Keep Enabled sandbox HTTP on the managed proxy. Clear Codex's default NO_PROXY after apply_to_env so loopback clients cannot bypass the helper proxy, and drop the unused isolation prepare_plan wrapper. Co-authored-by: Cursor --- crates/linux-sandbox/src/proxy.rs | 39 ++++++++++++++++++++++++- crates/linux-sandbox/tests/isolation.rs | 9 +++--- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/crates/linux-sandbox/src/proxy.rs b/crates/linux-sandbox/src/proxy.rs index 6b7af70..0a072b3 100644 --- a/crates/linux-sandbox/src/proxy.rs +++ b/crates/linux-sandbox/src/proxy.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use codex_network_proxy::{ NetworkProxy, NetworkProxyConfig, NetworkProxyHandle, NetworkProxyState, - RemoteNetworkProxyConfig, RemoteNetworkProxyLaunchConfig, + RemoteNetworkProxyConfig, RemoteNetworkProxyLaunchConfig, NO_PROXY_ENV_KEYS, }; pub fn run_codex_with_proxy(argv: &[String]) -> ! { @@ -33,6 +33,9 @@ pub fn run_codex_with_proxy(argv: &[String]) -> ! { }; let mut env: HashMap = std::env::vars().collect(); proxy.apply_to_env(&mut env); + // Local destinations are enforced by the proxy. Codex's default NO_PROXY + // would let sandbox clients bypass it for 127.0.0.1. + route_loopback_through_proxy(&mut env); let status = match spawn_codex(argv, &env) { Ok(mut child) => child.wait(), Err(err) => { @@ -108,3 +111,37 @@ fn become_group_leader() { libc::setpgid(0, 0); } } + +fn route_loopback_through_proxy(env: &mut HashMap) { + for key in NO_PROXY_ENV_KEYS { + env.insert((*key).to_string(), String::new()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_network_proxy::DEFAULT_NO_PROXY_VALUE; + + #[test] + fn route_loopback_through_proxy_clears_bypass_keys() { + let mut env = HashMap::new(); + env.insert("HTTP_PROXY".into(), "http://127.0.0.1:9".into()); + env.insert("HTTPS_PROXY".into(), "http://127.0.0.1:9".into()); + for key in NO_PROXY_ENV_KEYS { + env.insert((*key).to_string(), DEFAULT_NO_PROXY_VALUE.to_string()); + } + route_loopback_through_proxy(&mut env); + assert_eq!( + env.get("HTTP_PROXY").map(String::as_str), + Some("http://127.0.0.1:9") + ); + assert_eq!( + env.get("HTTPS_PROXY").map(String::as_str), + Some("http://127.0.0.1:9") + ); + for key in NO_PROXY_ENV_KEYS { + assert_eq!(env.get(*key).map(String::as_str), Some(""), "{key}"); + } + } +} diff --git a/crates/linux-sandbox/tests/isolation.rs b/crates/linux-sandbox/tests/isolation.rs index fcc8b9c..9e9d484 100644 --- a/crates/linux-sandbox/tests/isolation.rs +++ b/crates/linux-sandbox/tests/isolation.rs @@ -40,10 +40,6 @@ fn sandbox_exec_env(home: &Path) -> BTreeMap { env } -fn prepare_plan(root: &Path, command: &[String]) -> PathBuf { - prepare_plan_network(root, command, "restricted") -} - fn prepare_plan_network(root: &Path, command: &[String], network: &str) -> PathBuf { let request = serde_json::json!({ "protocol": 1, @@ -414,7 +410,10 @@ fn enabled_http_reaches_host_loopback_only_through_proxy() { python.display().to_string(), "-c".into(), format!( - "import urllib.request; print(urllib.request.urlopen({target:?}, timeout=4).read().decode())" + "import os, urllib.request\n\ +assert not os.environ.get('NO_PROXY')\n\ +assert not os.environ.get('no_proxy')\n\ +print(urllib.request.urlopen({target:?}, timeout=4).read().decode())" ), ], "enabled",