Expose a verified sandbox launcher to local MCP servers (#52182)

## Why

MCP servers receiving sandbox state need a usable Codex executable to launch commands with the issuing call's policy. HTTP and remote servers cannot assume access to the host's filesystem.

## What changed

- Include optional `codexExecutable` metadata for local stdio servers when the host supplies an absolute, UTF-8 path to a CLI supporting `sandbox --sandbox-state-json`. Cache capability probes by executable path and bound them to five seconds.
- Document forwarding the sandbox state unchanged and requiring an explicit opt-out rather than silently falling back to PATH lookup or unsandboxed execution when the launcher is absent.
- Honor `useMxc` from forwarded sandbox state when selecting the Windows sandbox backend and network proxy policies, preserving a parent's selection made through CLI overrides.

## Testing

Add coverage for launcher eligibility across local, remote, and HTTP servers, invalid or missing executable paths, and binaries without sandbox-state support. Extend metadata integration and serialization tests to cover the optional launcher field, legacy state, and unknown future fields.

GitOrigin-RevId: ea8cd3770616e5a620b423a01052a7bc828bceb9
This commit is contained in:
Michael Bolin
2026-10-08 17:25:09 +00:00
committed by copyberry
parent e9e6cf6349
commit 78a19f5ab5
14 changed files with 245 additions and 13 deletions
+3
View File
@@ -3884,8 +3884,11 @@ dependencies = [
"codex-plugin",
"codex-protocol",
"codex-rmcp-client",
"codex-utils-absolute-path",
"codex-utils-cargo-bin",
"codex-utils-path-uri",
"codex-utils-plugins",
"codex-utils-process",
"futures",
"insta",
"lru",
+11 -10
View File
@@ -294,6 +294,14 @@ async fn run_command_under_sandbox(
|| config.features.use_legacy_landlock(),
|state| state.use_legacy_landlock,
);
// The parent can select MXC through a CLI override absent from the child's
// disk config. Preserve that backend rather than weakening enforcement.
// An explicit MXC launch fails closed if the native implementation is unavailable.
let windows_sandbox_type = if sandbox_state.as_ref().is_some_and(|state| state.use_mxc) {
codex_sandboxing::SandboxType::WindowsMxc
} else {
config.effective_local_windows_sandbox_type()
};
match permission_profile.enforcement() {
SandboxEnforcement::Managed => {}
@@ -319,9 +327,7 @@ async fn run_command_under_sandbox(
if let SandboxType::Windows = sandbox_type {
#[cfg(target_os = "windows")]
{
if config.effective_local_windows_sandbox_type()
!= codex_sandboxing::SandboxType::WindowsMxc
{
if windows_sandbox_type != codex_sandboxing::SandboxType::WindowsMxc {
let workspace_roots = config
.effective_workspace_roots()
.iter()
@@ -357,13 +363,8 @@ async fn run_command_under_sandbox(
Some(spec) => Some(
spec.start_proxy(
&permission_profile,
managed_proxy_routing_for_windows_sandbox(
config.effective_local_windows_sandbox_type(),
),
local_binding_policy_for_sandbox(
config.effective_local_windows_sandbox_type(),
Some(std::env::consts::OS),
),
managed_proxy_routing_for_windows_sandbox(windows_sandbox_type),
local_binding_policy_for_sandbox(windows_sandbox_type, Some(std::env::consts::OS)),
/*policy_decider*/ None,
/*blocked_request_observer*/ None,
managed_network_requirements_enabled,
+1
View File
@@ -3,5 +3,6 @@ load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "codex-mcp",
crate_name = "codex_mcp",
extra_binaries = ["//codex-rs/cli:codex"],
test_data_extra = glob(["src/**/snapshots/**"]),
)
+3
View File
@@ -27,8 +27,10 @@ codex-model-provider = { workspace = true }
codex-otel = { workspace = true }
codex-protocol = { workspace = true }
codex-rmcp-client = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-path-uri = { workspace = true }
codex-utils-plugins = { workspace = true }
codex-utils-process = { workspace = true }
futures = { workspace = true }
lru = { workspace = true }
regex-lite = { workspace = true }
@@ -46,6 +48,7 @@ url = { workspace = true }
assert_matches = { workspace = true }
codex-exec-server-test-support = { workspace = true }
codex-plugin = { workspace = true }
codex-utils-cargo-bin = { workspace = true }
insta = { workspace = true }
pretty_assertions = { workspace = true }
rmcp = { workspace = true, default-features = false, features = ["base64", "macros", "schemars", "server"] }
+32
View File
@@ -11,6 +11,7 @@ use anyhow::Result;
use codex_config::AppToolApproval;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::models::PermissionProfile;
use codex_utils_absolute_path::AbsolutePathBuf;
use rmcp::model::ListResourceTemplatesResult;
use rmcp::model::ListResourcesResult;
use rmcp::model::PaginatedRequestParams;
@@ -258,6 +259,37 @@ impl PreparedMcpCall {
permission_profile
}
/// Full CLI path usable by a local stdio server. An HTTP endpoint (even on
/// loopback) or remote executor does not establish a shared filesystem.
/// Capability probes are cached process-wide by absolute executable path.
pub async fn sandbox_codex_executable(&self) -> Option<AbsolutePathBuf> {
let server = self
.config
.mcp_server_catalog
.server(&self.server_name)?
.config();
if self.server_environment_id() != codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID
|| !server.is_local_environment()
|| !matches!(
server.transport,
codex_config::McpServerTransportConfig::Stdio { .. }
)
{
return None;
}
// Require a literal absolute path before the constructor can expand `~`.
let executable = self
.config
.codex_self_exe
.as_deref()
.filter(|path| path.is_absolute() && path.to_str().is_some())?;
let executable = AbsolutePathBuf::from_absolute_path_checked(executable).ok()?;
crate::sandbox_executable::supports_sandbox_state(&executable)
.await
.then_some(executable)
}
pub fn server_name(&self) -> &str {
&self.server_name
}
+82
View File
@@ -12,6 +12,7 @@ use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_rmcp_client::InProcessTransportFactory;
use codex_rmcp_client::RmcpClient;
use codex_utils_absolute_path::AbsolutePathBuf;
use futures::FutureExt;
use pretty_assertions::assert_eq;
use rmcp::model::JsonObject;
@@ -409,3 +410,84 @@ async fn preparation_holds_catalog_authority_until_it_finishes() {
.await
.expect("catalog refresh should finish after preparation");
}
#[tokio::test]
async fn sandbox_executable_requires_local_stdio_and_a_full_cli() -> anyhow::Result<()> {
#[cfg(unix)]
use std::os::unix::ffi::OsStrExt;
let step = test_step(
"old",
AppToolApproval::Approve,
/*supports_sandbox_state_meta*/ true,
)
.await;
let call = step
.step
.prepare_call(SERVER_NAME, TOOL_NAME)
.expect("prepared call");
// Use a probe-capable CLI so remote/HTTP exclusions cannot pass merely
// because the supplied executable lacks the sandbox capability.
let executable = codex_utils_cargo_bin::cargo_bin("codex")?;
for (server, runtime_environment, path, expected) in [
(
serde_json::json!({"command": "mcp"}),
"local",
Some(executable.clone()),
Some(AbsolutePathBuf::from_absolute_path_checked(&executable)?),
),
// The Rust test harness accepts `sandbox --help`, but must not be
// advertised as a sandbox launcher: it lacks `--sandbox-state-json`.
(
serde_json::json!({"command": "mcp"}),
"local",
Some(std::env::current_exe()?),
None,
),
(serde_json::json!({"command": "mcp"}), "local", None, None),
#[cfg(unix)]
(
serde_json::json!({"command": "mcp"}),
"local",
Some(std::ffi::OsStr::from_bytes(b"/non-utf8-\xff/codex").into()),
None,
),
(
serde_json::json!({"command": "mcp"}),
"local",
Some("codex".into()),
None,
),
(
serde_json::json!({"command": "mcp", "environment_id": "remote"}),
"remote",
Some(executable.clone()),
None,
),
(
serde_json::json!({"command": "mcp"}),
"remote",
Some(executable.clone()),
None,
),
(
serde_json::json!({"url": "http://127.0.0.1:8080/mcp"}),
"local",
Some(executable),
None,
),
] {
let mut call = call.clone();
let config = Arc::make_mut(&mut call.config);
config.codex_self_exe = path;
let mut catalog = crate::ResolvedMcpCatalog::builder();
catalog.register(crate::McpServerRegistration::from_config(
SERVER_NAME.to_owned(),
serde_json::from_value(server)?,
));
config.mcp_server_catalog = catalog.build();
call.server_metadata.environment_id = runtime_environment.to_owned();
assert_eq!(call.sandbox_codex_executable().await, expected);
}
Ok(())
}
+1
View File
@@ -122,6 +122,7 @@ mod resource_client;
mod resource_origin;
pub(crate) mod rmcp_client;
pub(crate) mod runtime;
mod sandbox_executable;
pub(crate) mod server;
mod tool_catalog_cache;
pub(crate) mod tools;
+3
View File
@@ -164,6 +164,9 @@ pub struct McpConfig {
pub environment_use_mxc: HashMap<String, bool>,
/// Explicit server permissions; unresolved or unavailable servers have no entry.
pub server_permission_profiles: HashMap<String, PermissionProfile>,
/// Host-supplied re-exec binary. Probe its sandbox capability before advertising it.
/// Never infer this from PATH or an MCP server's configuration.
pub codex_self_exe: Option<PathBuf>,
/// Optional path to `codex-linux-sandbox` for sandboxed MCP tool execution.
pub codex_linux_sandbox_exe: Option<PathBuf>,
/// Whether to use legacy Landlock behavior in the MCP sandbox state.
+1
View File
@@ -105,6 +105,7 @@ pub(crate) fn test_mcp_config(codex_home: PathBuf) -> McpConfig {
environment_cwds: HashMap::new(),
environment_use_mxc: HashMap::new(),
server_permission_profiles: HashMap::new(),
codex_self_exe: None,
codex_linux_sandbox_exe: None,
use_legacy_landlock: false,
apps_enabled: false,
+40 -3
View File
@@ -784,9 +784,35 @@ impl McpRuntime {
}
}
/// Opaque sandbox state forwarded unchanged to `codex sandbox --sandbox-state-json`.
/// Servers advertise `codex/sandbox-state-meta` to receive it on tool calls.
///
/// `codexExecutable` is the one field servers may interpret: invoke that absolute
/// path with `sandbox --sandbox-state-json <the original JSON object> -- <command>`.
/// Do not deserialize and reconstruct the opaque state in a consumer: doing so
/// can discard future policy fields. The state describes the issuing call, so a
/// long-lived subprocess must retain its launch state and must not be reused for
/// a different policy without an explicit lifecycle decision.
///
/// MCP probes the host's existing re-exec binary once to verify this contract.
/// Standalone binaries without the capability omit it; HTTP and remote-executor
/// servers receive no host-local path even when the host has a full CLI.
/// Older clients omit the field and older CLI consumers ignore it when decoding.
/// Servers that require it must fail closed or require an explicit operator
/// opt-out, never silently search PATH or execute an unsandboxed command.
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SandboxState {
/// Absolute path to a full Codex CLI on the MCP server's host, supporting
/// `sandbox --sandbox-state-json`. Consumers may read this field to launch
/// the CLI, but must forward the entire state unchanged.
///
/// Absent for older clients, embeddings without a full CLI, and transports
/// or remote environments where a usable server-host executable is unknown.
/// Also omitted when the OS path cannot be represented as a JSON string.
/// Absence does not authorize an unsandboxed fallback or a PATH lookup.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codex_executable: Option<PathBuf>,
pub permission_profile: PermissionProfile,
pub codex_linux_sandbox_exe: Option<PathBuf>,
pub sandbox_cwd: PathUri,
@@ -1161,6 +1187,7 @@ mod tests {
)
.expect("current directory should convert to a URI");
let sandbox_state = SandboxState {
codex_executable: None,
permission_profile: PermissionProfile::workspace_write(),
codex_linux_sandbox_exe: None,
sandbox_cwd,
@@ -1204,10 +1231,20 @@ mod tests {
);
let deserialized: SandboxState =
serde_json::from_value(serialized).expect("deserialize sandbox state");
serde_json::from_value(serialized.clone()).expect("deserialize legacy sandbox state");
assert_eq!(deserialized, sandbox_state);
let executable = std::env::current_exe().expect("absolute executable path");
let mut extended = serialized;
extended["codexExecutable"] = serde_json::json!(executable);
extended["futureField"] = serde_json::json!({"preservedByOpaqueConsumers": true});
let deserialized: SandboxState = serde_json::from_value(extended).expect("extended state");
assert_eq!(
deserialized.permission_profile,
sandbox_state.permission_profile
deserialized,
SandboxState {
codex_executable: Some(executable),
..sandbox_state
}
);
}
@@ -0,0 +1,50 @@
//! Verify that the host's re-exec binary supports the sandbox metadata contract.
//! Cache completed probes per absolute executable path; never infer capability from its name or PATH.
use std::collections::HashMap;
use std::process::Stdio;
use std::sync::Arc;
use std::sync::LazyLock;
use std::time::Duration;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_process::background_command;
use tokio::sync::Mutex;
use tokio::sync::OnceCell;
const PROBE_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 5);
static PROBES: LazyLock<Mutex<HashMap<AbsolutePathBuf, Arc<OnceCell<bool>>>>> =
LazyLock::new(Mutex::default);
pub(crate) async fn supports_sandbox_state(executable: &AbsolutePathBuf) -> bool {
let probe = {
let mut probes = PROBES.lock().await;
Arc::clone(probes.entry(executable.clone()).or_default())
};
*probe
.get_or_init(|| async {
matches!(
tokio::time::timeout(PROBE_TIMEOUT, probe_sandbox_state(executable)).await,
Ok(Ok(true))
)
})
.await
}
/// Check that the binary accepts the sandbox-state option without launching a sandbox.
///
/// Plain `sandbox --help` can also succeed for unrelated binaries, including Rust test
/// harnesses. Put `--sandbox-state-json` before `--help` so those parsers reject the
/// unknown option. Codex handles help before interpreting the placeholder `{}` policy
/// or loading configuration; only its exit status matters, not the help text.
async fn probe_sandbox_state(executable: &AbsolutePathBuf) -> std::io::Result<bool> {
let status = tokio::process::Command::from(background_command(executable.as_os_str()))
.args(["sandbox", "--sandbox-state-json", "{}", "--help"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.kill_on_drop(true)
.status()
.await?;
Ok(status.success())
}
+1
View File
@@ -1869,6 +1869,7 @@ impl Config {
environment_cwds: HashMap::new(),
environment_use_mxc: HashMap::new(),
server_permission_profiles: HashMap::new(),
codex_self_exe: self.codex_self_exe.clone(),
codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(),
use_legacy_landlock: self.features.use_legacy_landlock(),
apps_enabled: self.features.enabled(Feature::Apps),
+4
View File
@@ -876,6 +876,10 @@ async fn augment_mcp_tool_request_meta_with_sandbox_state(
// TODO(anp): Build this metadata from the server's captured
// TurnEnvironment::sandbox_context instead of the runtime-wide Landlock value.
let sandbox_state = serde_json::to_value(SandboxState {
codex_executable: prepared_call
.sandbox_codex_executable()
.await
.map(AbsolutePathBuf::into_path_buf),
permission_profile: prepared_call.permission_profile().clone(),
codex_linux_sandbox_exe: prepared_call.config().codex_linux_sandbox_exe.clone(),
sandbox_cwd,
+13
View File
@@ -92,6 +92,7 @@ use codex_protocol::request_user_input::RequestUserInputAnswer;
use codex_protocol::request_user_input::RequestUserInputResponse;
use codex_protocol::turn_input::TurnInput;
use codex_protocol::user_input::UserInput;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_cargo_bin::cargo_bin;
use codex_utils_path_uri::PathUri;
use core_test_support::apps_test_server::AppsTestServer;
@@ -2228,6 +2229,14 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta(
.with_model("gpt-5.5")
.with_config(move |config| {
config.prefer_mxc = prefer_mxc;
// The real CLI is explicitly provided; the test harness is not a CLI.
config.codex_self_exe = Some(
AbsolutePathBuf::from_absolute_path_checked(
cargo_bin("codex").expect("codex binary"),
)
.expect("absolute codex binary path")
.into_path_buf(),
);
insert_mcp_server(
config,
server_name,
@@ -2432,6 +2441,10 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta(
assert_eq!(
sandbox_state,
SandboxState {
codex_executable: (remote_aware_environment_id()
== codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID)
.then(|| fixture.config.codex_self_exe.clone())
.flatten(),
permission_profile: owner_permission_profile
.materialize_project_roots_with_path_uris(&owner_workspace_roots),
codex_linux_sandbox_exe: fixture.config.codex_linux_sandbox_exe.clone(),