mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
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:
Generated
+3
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/**"]),
|
||||
)
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user