sandboxing: migrate cwd inputs to PathUri (#27816)

## Why

Sandbox cwd values can cross app-server and exec-server host boundaries.
They should retain URI semantics until the receiving host validates them
instead of being interpreted early as native paths.

## What

- Carry `PathUri` through filesystem sandbox contexts, sandbox commands,
and transform inputs.
- Convert command and policy cwd once in `SandboxManager::transform`,
then keep launch requests native.
- Preserve sandbox cwd over remote filesystem transport and reject
non-native URIs without fallback.
- Cache paired native/URI turn-environment cwd values during migration,
with immutable access to keep them synchronized.
- Extend existing protocol, forwarding, transform, and core runtime
tests.
This commit is contained in:
Adam Perry @ OpenAI
2026-06-12 11:38:01 -07:00
committed by GitHub
Unverified
parent 84520225b9
commit 52a50aec70
40 changed files with 546 additions and 228 deletions
+81 -15
View File
@@ -15,6 +15,7 @@ use codex_sandboxing::SandboxTransformRequest;
use codex_sandboxing::SandboxablePreference;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::canonicalize_preserving_symlinks;
use codex_utils_path_uri::PathUri;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
@@ -39,6 +40,12 @@ const FS_HELPER_BAZEL_BWRAP_ENV_ALLOWLIST: &[&str] = &[
"TEST_WORKSPACE",
];
#[derive(Debug, PartialEq, Eq)]
struct SandboxCwd {
uri: PathUri,
native: AbsolutePathBuf,
}
#[derive(Clone, Debug)]
pub(crate) struct FileSystemSandboxRunner {
runtime_paths: ExecServerRuntimePaths,
@@ -65,7 +72,11 @@ impl FileSystemSandboxRunner {
} else {
helper_read_roots(&self.runtime_paths)
};
add_helper_runtime_permissions(&mut file_system_policy, &helper_read_roots, cwd.as_path());
add_helper_runtime_permissions(
&mut file_system_policy,
&helper_read_roots,
cwd.native.as_path(),
);
normalize_file_system_policy_root_aliases(&mut file_system_policy);
let network_policy = NetworkSandboxPolicy::Restricted;
let permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement(
@@ -73,7 +84,7 @@ impl FileSystemSandboxRunner {
&file_system_policy,
network_policy,
);
let command = self.sandbox_exec_request(&permission_profile, &cwd, sandbox)?;
let command = self.sandbox_exec_request(&permission_profile, &cwd.uri, sandbox)?;
let request_json = serde_json::to_vec(&request).map_err(json_error)?;
run_command(command, request_json).await
}
@@ -81,7 +92,7 @@ impl FileSystemSandboxRunner {
fn sandbox_exec_request(
&self,
permission_profile: &PermissionProfile,
cwd: &AbsolutePathBuf,
cwd: &PathUri,
sandbox_context: &FileSystemSandboxContext,
) -> Result<SandboxExecRequest, JSONRPCErrorError> {
let helper = &self.runtime_paths.codex_self_exe;
@@ -108,7 +119,7 @@ impl FileSystemSandboxRunner {
sandbox,
enforce_managed_network: false,
network: None,
sandbox_policy_cwd: cwd.as_path(),
sandbox_policy_cwd: cwd,
codex_linux_sandbox_exe: self.runtime_paths.codex_linux_sandbox_exe.as_deref(),
use_legacy_landlock: sandbox_context.use_legacy_landlock,
windows_sandbox_level: sandbox_context.windows_sandbox_level,
@@ -118,9 +129,12 @@ impl FileSystemSandboxRunner {
}
}
fn sandbox_cwd(sandbox: &FileSystemSandboxContext) -> Result<AbsolutePathBuf, JSONRPCErrorError> {
if let Some(cwd) = &sandbox.cwd {
return Ok(cwd.clone());
fn sandbox_cwd(sandbox: &FileSystemSandboxContext) -> Result<SandboxCwd, JSONRPCErrorError> {
if let Some(uri) = &sandbox.cwd {
return Ok(SandboxCwd {
native: native_sandbox_cwd(uri)?,
uri: uri.clone(),
});
}
if sandbox.has_cwd_dependent_permissions() {
@@ -129,9 +143,22 @@ fn sandbox_cwd(sandbox: &FileSystemSandboxContext) -> Result<AbsolutePathBuf, JS
));
}
let cwd = current_sandbox_cwd().map_err(io_error)?;
AbsolutePathBuf::from_absolute_path(cwd.as_path())
.map_err(|err| invalid_request(format!("current directory is not absolute: {err}")))
let native = AbsolutePathBuf::from_absolute_path(current_sandbox_cwd().map_err(io_error)?)
.map_err(|err| invalid_request(format!("current directory is not absolute: {err}")))?;
let uri = PathUri::from_abs_path(&native).map_err(|err| {
invalid_request(format!(
"current directory cannot be represented as a file URI: {err}"
))
})?;
Ok(SandboxCwd { uri, native })
}
fn native_sandbox_cwd(cwd: &PathUri) -> Result<AbsolutePathBuf, JSONRPCErrorError> {
cwd.to_abs_path().map_err(|err| {
invalid_request(format!(
"file system sandbox cwd is not native to this exec-server host: {err}"
))
})
}
fn helper_read_roots(runtime_paths: &ExecServerRuntimePaths) -> Vec<AbsolutePathBuf> {
@@ -327,11 +354,13 @@ mod tests {
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use pretty_assertions::assert_eq;
use crate::ExecServerRuntimePaths;
use super::FileSystemSandboxRunner;
use super::SandboxCwd;
use super::add_helper_runtime_permissions;
use super::helper_env;
use super::helper_env_from_vars;
@@ -489,9 +518,10 @@ mod tests {
ExecServerRuntimePaths::new(codex_self_exe.clone(), Some(codex_self_exe))
.expect("runtime paths");
let runner = FileSystemSandboxRunner::new(runtime_paths);
let cwd = AbsolutePathBuf::current_dir().expect("cwd");
let native_cwd = AbsolutePathBuf::current_dir().expect("cwd");
let cwd = PathUri::from_abs_path(&native_cwd).expect("cwd URI");
let file_system_policy =
restricted_policy(vec![path_entry(cwd.clone(), FileSystemAccessMode::Write)]);
restricted_policy(vec![path_entry(native_cwd, FileSystemAccessMode::Write)]);
let network_policy = NetworkSandboxPolicy::Restricted;
let permission_profile =
PermissionProfile::from_runtime_permissions(&file_system_policy, network_policy);
@@ -506,15 +536,42 @@ mod tests {
#[test]
fn sandbox_cwd_uses_context_cwd() {
let cwd = AbsolutePathBuf::from_absolute_path(std::env::temp_dir().as_path())
let native_cwd = AbsolutePathBuf::from_absolute_path(std::env::temp_dir().as_path())
.expect("absolute cwd");
let cwd = PathUri::from_abs_path(&native_cwd).expect("cwd URI");
let policy = restricted_policy(vec![special_entry(
FileSystemSpecialPath::project_roots(/*subpath*/ None),
FileSystemAccessMode::Write,
)]);
let sandbox_context = sandbox_context_with_cwd(&policy, cwd.clone());
assert_eq!(sandbox_cwd(&sandbox_context).expect("sandbox cwd"), cwd);
assert_eq!(
sandbox_cwd(&sandbox_context).expect("sandbox cwd"),
SandboxCwd {
uri: cwd,
native: native_cwd
}
);
}
#[test]
fn sandbox_cwd_rejects_non_native_context_cwd_without_fallback() {
let cwd = non_native_cwd();
let policy = restricted_policy(vec![special_entry(
FileSystemSpecialPath::project_roots(/*subpath*/ None),
FileSystemAccessMode::Write,
)]);
let sandbox_context = sandbox_context_with_cwd(&policy, cwd);
let err = sandbox_cwd(&sandbox_context).expect_err("non-native cwd should be rejected");
assert_eq!(
err,
crate::rpc::invalid_request(
"file system sandbox cwd is not native to this exec-server host: file URI contains an invalid absolute path"
.to_string()
)
);
}
#[test]
@@ -595,7 +652,7 @@ mod tests {
fn sandbox_context_with_cwd(
policy: &FileSystemSandboxPolicy,
cwd: AbsolutePathBuf,
cwd: PathUri,
) -> crate::FileSystemSandboxContext {
crate::FileSystemSandboxContext::from_permission_profile_with_cwd(
PermissionProfile::from_runtime_permissions(policy, NetworkSandboxPolicy::Restricted),
@@ -603,6 +660,15 @@ mod tests {
)
}
fn non_native_cwd() -> PathUri {
#[cfg(unix)]
let uri = "file://server/share/checkout";
#[cfg(windows)]
let uri = "file:///usr/local/checkout";
PathUri::parse(uri).expect("non-native cwd URI")
}
fn path_entry(path: AbsolutePathBuf, access: FileSystemAccessMode) -> FileSystemSandboxEntry {
FileSystemSandboxEntry {
path: FileSystemPath::Path { path },
+14 -3
View File
@@ -450,6 +450,8 @@ mod base64_bytes {
mod tests {
use super::FsReadFileParams;
use super::HttpRequestParams;
use crate::FileSystemSandboxContext;
use codex_protocol::models::PermissionProfile;
use codex_utils_path_uri::PathUri;
use pretty_assertions::assert_eq;
@@ -458,14 +460,22 @@ mod tests {
let legacy_path = std::env::current_dir()
.expect("current directory")
.join("legacy-file.txt");
let legacy_cwd = std::env::current_dir().expect("current directory");
let expected_sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd(
PermissionProfile::default(),
PathUri::from_path(&legacy_cwd).expect("cwd URI"),
);
let mut legacy_sandbox =
serde_json::to_value(&expected_sandbox).expect("sandbox should serialize");
legacy_sandbox["cwd"] = serde_json::json!(legacy_cwd.to_string_lossy());
let params: FsReadFileParams = serde_json::from_value(serde_json::json!({
"path": legacy_path.to_string_lossy(),
"sandbox": null,
"sandbox": legacy_sandbox,
}))
.expect("legacy absolute path should deserialize");
let expected = FsReadFileParams {
path: PathUri::from_path(legacy_path).expect("path URI"),
sandbox: None,
sandbox: Some(expected_sandbox.clone()),
};
assert_eq!(params, expected);
@@ -473,7 +483,8 @@ mod tests {
serde_json::to_value(params).expect("params should serialize"),
serde_json::json!({
"path": expected.path.to_string(),
"sandbox": null,
"sandbox": serde_json::to_value(expected_sandbox)
.expect("sandbox should serialize"),
})
);
}
@@ -321,6 +321,7 @@ mod tests {
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use pretty_assertions::assert_eq;
use super::*;
@@ -337,7 +338,7 @@ mod tests {
PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted);
let sandbox_context = FileSystemSandboxContext::from_permission_profile_with_cwd(
permissions,
absolute_test_path("host-checkout"),
path_uri("host-checkout"),
);
let remote_context =
@@ -356,7 +357,7 @@ mod tests {
}]);
let permissions =
PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted);
let cwd = absolute_test_path("host-checkout");
let cwd = path_uri("host-checkout");
let sandbox_context =
FileSystemSandboxContext::from_permission_profile_with_cwd(permissions, cwd.clone());
@@ -400,4 +401,8 @@ mod tests {
let path = std::env::temp_dir().join(name);
AbsolutePathBuf::from_absolute_path(&path).expect("absolute path")
}
fn path_uri(name: &str) -> PathUri {
PathUri::from_abs_path(&absolute_test_path(name)).expect("path URI")
}
}
@@ -2,6 +2,13 @@
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCResponse;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_utils_path_uri::PathUri;
use futures::SinkExt;
use futures::StreamExt;
@@ -25,9 +32,9 @@ use crate::protocol::INITIALIZED_METHOD;
use crate::protocol::InitializeResponse;
#[tokio::test]
async fn remote_file_system_sends_path_uris_without_native_conversion() {
let (websocket_url, captured_paths, server) =
record_read_file_paths(/*expected_requests*/ 2).await;
async fn remote_file_system_sends_path_and_sandbox_cwd_uris_without_native_conversion() {
let (websocket_url, captured_params, server) =
record_read_file_params(/*expected_requests*/ 2).await;
let file_system = RemoteFileSystem::new(LazyRemoteExecServerClient::new(
ExecServerTransportParams::websocket_url(websocket_url),
));
@@ -35,33 +42,54 @@ async fn remote_file_system_sends_path_uris_without_native_conversion() {
PathUri::parse("file:///C:/Users/Alice/src/main.rs").expect("valid drive URI"),
PathUri::parse("file://server/share/src/main.rs").expect("valid UNC URI"),
];
let sandbox_cwd = non_native_cwd();
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
},
access: FileSystemAccessMode::Write,
}]);
let sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd(
PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted),
sandbox_cwd,
);
for path in &paths {
assert_eq!(
file_system
.read_file(path, /*sandbox*/ None)
.read_file(path, Some(&sandbox))
.await
.expect("remote read should succeed"),
Vec::<u8>::new()
);
}
assert_eq!(captured_paths.await.expect("captured paths"), paths);
let expected_params = paths
.into_iter()
.map(|path| FsReadFileParams {
path,
sandbox: Some(sandbox.clone()),
})
.collect::<Vec<_>>();
assert_eq!(
captured_params.await.expect("captured params"),
expected_params
);
server.await.expect("recording server should succeed");
}
async fn record_read_file_paths(
async fn record_read_file_params(
expected_requests: usize,
) -> (
String,
oneshot::Receiver<Vec<PathUri>>,
oneshot::Receiver<Vec<FsReadFileParams>>,
tokio::task::JoinHandle<()>,
) {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let websocket_url = format!("ws://{}", listener.local_addr().expect("listener address"));
let (captured_paths_tx, captured_paths_rx) = oneshot::channel();
let (captured_params_tx, captured_params_rx) = oneshot::channel();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("listener should accept");
let mut websocket = accept_async(stream)
@@ -69,7 +97,7 @@ async fn record_read_file_paths(
.expect("websocket handshake should succeed");
complete_websocket_initialize(&mut websocket).await;
let mut captured_paths = Vec::with_capacity(expected_requests);
let mut captured_params = Vec::with_capacity(expected_requests);
for _ in 0..expected_requests {
let request = match read_jsonrpc_websocket(&mut websocket).await {
JSONRPCMessage::Request(request) if request.method == FS_READ_FILE_METHOD => {
@@ -80,7 +108,7 @@ async fn record_read_file_paths(
let params: FsReadFileParams =
serde_json::from_value(request.params.expect("fs/readFile params should exist"))
.expect("fs/readFile params should deserialize");
captured_paths.push(params.path);
captured_params.push(params);
write_jsonrpc_websocket(
&mut websocket,
JSONRPCMessage::Response(JSONRPCResponse {
@@ -93,12 +121,21 @@ async fn record_read_file_paths(
)
.await;
}
captured_paths_tx
.send(captured_paths)
.expect("captured paths receiver should stay open");
captured_params_tx
.send(captured_params)
.expect("captured params receiver should stay open");
});
(websocket_url, captured_paths_rx, server)
(websocket_url, captured_params_rx, server)
}
fn non_native_cwd() -> PathUri {
#[cfg(unix)]
let uri = "file://server/share/checkout";
#[cfg(windows)]
let uri = "file:///usr/local/checkout";
PathUri::parse(uri).expect("non-native cwd URI")
}
async fn complete_websocket_initialize(websocket: &mut WebSocketStream<TcpStream>) {
@@ -189,7 +189,6 @@ fn map_fs_error(err: io::Error) -> JSONRPCErrorError {
mod tests {
use codex_protocol::protocol::NetworkAccess;
use codex_protocol::protocol::SandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use pretty_assertions::assert_eq;
@@ -207,8 +206,14 @@ mod tests {
)
.expect("runtime paths");
let handler = FileSystemHandler::new(runtime_paths);
let sandbox_cwd =
AbsolutePathBuf::from_absolute_path(temp_dir.path()).expect("absolute tempdir");
let sandbox_cwd = PathUri::from_path(temp_dir.path()).expect("tempdir URI");
let sandbox_context = |sandbox_policy| {
FileSystemSandboxContext::from_legacy_sandbox_policy(
sandbox_policy,
sandbox_cwd.clone(),
)
.expect("sandbox context")
};
for (file_name, sandbox_policy) in [
("danger.txt", SandboxPolicy::DangerFullAccess),
@@ -225,10 +230,7 @@ mod tests {
.write_file(FsWriteFileParams {
path: path.clone(),
data_base64: STANDARD.encode("ok"),
sandbox: Some(FileSystemSandboxContext::from_legacy_sandbox_policy(
sandbox_policy.clone(),
sandbox_cwd.clone(),
)),
sandbox: Some(sandbox_context(sandbox_policy.clone())),
})
.await
.expect("write file");
@@ -236,10 +238,7 @@ mod tests {
let canonicalized = handler
.canonicalize(FsCanonicalizeParams {
path: path.clone(),
sandbox: Some(FileSystemSandboxContext::from_legacy_sandbox_policy(
sandbox_policy.clone(),
sandbox_cwd.clone(),
)),
sandbox: Some(sandbox_context(sandbox_policy.clone())),
})
.await
.expect("canonicalize file");
@@ -254,10 +253,7 @@ mod tests {
let response = handler
.read_file(FsReadFileParams {
path,
sandbox: Some(FileSystemSandboxContext::from_legacy_sandbox_policy(
sandbox_policy,
sandbox_cwd.clone(),
)),
sandbox: Some(sandbox_context(sandbox_policy)),
})
.await
.expect("read file");