mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
Delegate remote process sandboxing to the executor (#37480)
## What changed - Preserve executor-native working directories, workspace roots, and permission profiles when preparing remote `exec_command` requests instead of resolving them through the host platform. - Send sandbox intent to the remote executor, including a restricted-token default for Windows executors and `-NoProfile` for elevated PowerShell commands. - Reject intercepted cross-platform `apply_patch` commands when filesystem writes are restricted, while continuing to allow them for unsandboxed or full-disk-write profiles. ## Testing Extended remote unified-exec coverage for foreign Windows workspaces, elevated PowerShell, sandbox metadata, and restricted and unrestricted intercepted patches. GitOrigin-RevId: 9fd328879fa2c15594fd58e40b6b32e8ba0847e7
This commit is contained in:
1 parent
92fb33b758
commit
511262b984
8 files changed
+284
-44
No files matched your search
@@ -6,10 +6,19 @@ use codex_config::test_support::CloudConfigBundleFixture;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::config_types::ApprovalsReviewer;
|
||||
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_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::TurnEnvironmentSelection;
|
||||
use codex_protocol::protocol::TurnEnvironmentSelections;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use core_test_support::managed_network_requirements_loader;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
@@ -43,9 +52,14 @@ const REPLAY_OUTPUT_EVENT_COUNT: u64 = 1024;
|
||||
const REPLAY_RETAINED_OUTPUT_SEQ: u64 = 800;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[cfg_attr(windows, allow(dead_code))]
|
||||
enum PushedExecScenario {
|
||||
Complete,
|
||||
DirectDenied,
|
||||
ElevatedPowerShell,
|
||||
InterceptedPatch,
|
||||
UnsandboxedInterceptedPatch,
|
||||
FullDiskInterceptedPatch,
|
||||
LegacyExit,
|
||||
ReplayGap,
|
||||
}
|
||||
@@ -103,19 +117,31 @@ async fn accept_initialized_exec_server(listener: TcpListener) -> WebSocketStrea
|
||||
websocket
|
||||
}
|
||||
|
||||
async fn send_environment_info(websocket: &mut WebSocketStream<TcpStream>) {
|
||||
async fn send_environment_info(
|
||||
websocket: &mut WebSocketStream<TcpStream>,
|
||||
scenario: PushedExecScenario,
|
||||
) {
|
||||
let info = read_exec_server_json(websocket).await;
|
||||
assert_eq!(info["method"], "environment/info");
|
||||
respond_environment_info(websocket, &info["id"]).await;
|
||||
respond_environment_info(websocket, &info["id"], scenario).await;
|
||||
}
|
||||
|
||||
async fn respond_environment_info(websocket: &mut WebSocketStream<TcpStream>, id: &Value) {
|
||||
async fn respond_environment_info(
|
||||
websocket: &mut WebSocketStream<TcpStream>,
|
||||
id: &Value,
|
||||
scenario: PushedExecScenario,
|
||||
) {
|
||||
let shell = if matches!(scenario, PushedExecScenario::ElevatedPowerShell) {
|
||||
json!({ "name": "powershell", "path": "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" })
|
||||
} else {
|
||||
json!({ "name": "zsh", "path": "/bin/zsh" })
|
||||
};
|
||||
send_exec_server_json(
|
||||
websocket,
|
||||
json!({
|
||||
"id": id,
|
||||
"result": {
|
||||
"shell": { "name": "zsh", "path": "/bin/zsh" },
|
||||
"shell": shell,
|
||||
"capabilities": { "networkProxyLaunch": true }
|
||||
}
|
||||
}),
|
||||
@@ -127,15 +153,20 @@ async fn serve_exec_with_pushed_events(
|
||||
listener: TcpListener,
|
||||
scenario: PushedExecScenario,
|
||||
) -> PushedExecServerResult {
|
||||
let unrestricted_patch = matches!(
|
||||
scenario,
|
||||
PushedExecScenario::UnsandboxedInterceptedPatch
|
||||
| PushedExecScenario::FullDiskInterceptedPatch
|
||||
);
|
||||
let mut websocket = accept_initialized_exec_server(listener).await;
|
||||
send_environment_info(&mut websocket).await;
|
||||
send_environment_info(&mut websocket, scenario).await;
|
||||
|
||||
let process_start = loop {
|
||||
let request = read_exec_server_json(&mut websocket).await;
|
||||
match request["method"].as_str() {
|
||||
Some("process/start") => break request,
|
||||
Some("environment/info") => {
|
||||
respond_environment_info(&mut websocket, &request["id"]).await;
|
||||
respond_environment_info(&mut websocket, &request["id"], scenario).await;
|
||||
}
|
||||
Some("fs/getMetadata") => {
|
||||
send_exec_server_json(
|
||||
@@ -147,6 +178,24 @@ async fn serve_exec_with_pushed_events(
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Some("fs/readFile") if unrestricted_patch => {
|
||||
send_exec_server_json(
|
||||
&mut websocket,
|
||||
json!({
|
||||
"id": request["id"],
|
||||
"error": { "code": -32004, "message": "not found" }
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Some("fs/writeFile") if unrestricted_patch => {
|
||||
send_exec_server_json(&mut websocket, json!({ "id": request["id"], "result": {} }))
|
||||
.await;
|
||||
return PushedExecServerResult {
|
||||
process_read_requests: 0,
|
||||
process_start: request,
|
||||
};
|
||||
}
|
||||
Some("fs/canonicalize") => {
|
||||
send_exec_server_json(
|
||||
&mut websocket,
|
||||
@@ -229,7 +278,7 @@ async fn serve_exec_with_pushed_events(
|
||||
.await;
|
||||
|
||||
match scenario {
|
||||
PushedExecScenario::Complete => {
|
||||
PushedExecScenario::Complete | PushedExecScenario::ElevatedPowerShell => {
|
||||
let encoded_output = BASE64_STANDARD.encode(COMPLETE_OUTPUT);
|
||||
for message in [
|
||||
json!({
|
||||
@@ -273,6 +322,13 @@ async fn serve_exec_with_pushed_events(
|
||||
)
|
||||
.await;
|
||||
}
|
||||
PushedExecScenario::InterceptedPatch => {
|
||||
panic!("cross-platform intercepted patches must not start a remote process")
|
||||
}
|
||||
PushedExecScenario::UnsandboxedInterceptedPatch
|
||||
| PushedExecScenario::FullDiskInterceptedPatch => {
|
||||
panic!("unsandboxed intercepted patches must write through the remote filesystem")
|
||||
}
|
||||
PushedExecScenario::LegacyExit => {
|
||||
send_exec_server_json(
|
||||
&mut websocket,
|
||||
@@ -319,6 +375,16 @@ async fn serve_exec_with_pushed_events(
|
||||
"failure": null,
|
||||
"sandboxDenied": true,
|
||||
}),
|
||||
PushedExecScenario::ElevatedPowerShell => {
|
||||
panic!("elevated remote PowerShell must not read a remote process")
|
||||
}
|
||||
PushedExecScenario::InterceptedPatch => {
|
||||
panic!("cross-platform intercepted patches must not read a remote process")
|
||||
}
|
||||
PushedExecScenario::UnsandboxedInterceptedPatch
|
||||
| PushedExecScenario::FullDiskInterceptedPatch => {
|
||||
panic!("unsandboxed intercepted patches must not read a remote process")
|
||||
}
|
||||
PushedExecScenario::LegacyExit => json!({
|
||||
"chunks": [],
|
||||
"nextSeq": 3,
|
||||
@@ -390,17 +456,23 @@ async fn serve_exec_with_pushed_events(
|
||||
}
|
||||
}
|
||||
|
||||
#[test_case(PushedExecScenario::Complete, false, false ; "complete_event_stream")]
|
||||
#[test_case(PushedExecScenario::DirectDenied, false, false ; "direct_sandbox_denial")]
|
||||
#[test_case(PushedExecScenario::LegacyExit, false, false ; "legacy_exit_metadata")]
|
||||
#[test_case(PushedExecScenario::ReplayGap, false, false ; "truncated_event_replay")]
|
||||
#[test_case(PushedExecScenario::Complete, true, true ; "managed_network_uses_executor_proxy_launch")]
|
||||
#[test_case(PushedExecScenario::Complete, true, false ; "strict_managed_allowlist_omits_policy_callbacks")]
|
||||
#[test_case(PushedExecScenario::Complete, false, false, false ; "complete_event_stream")]
|
||||
#[test_case(PushedExecScenario::DirectDenied, false, false, false ; "direct_sandbox_denial")]
|
||||
#[test_case(PushedExecScenario::LegacyExit, false, false, false ; "legacy_exit_metadata")]
|
||||
#[test_case(PushedExecScenario::ReplayGap, false, false, false ; "truncated_event_replay")]
|
||||
#[test_case(PushedExecScenario::Complete, true, true, false ; "managed_network_uses_executor_proxy_launch")]
|
||||
#[test_case(PushedExecScenario::Complete, true, false, false ; "strict_managed_allowlist_omits_policy_callbacks")]
|
||||
#[cfg_attr(not(windows), test_case(PushedExecScenario::Complete, false, false, true ; "foreign_windows_workspace_sandbox"))]
|
||||
#[test_case(PushedExecScenario::ElevatedPowerShell, false, false, true ; "windows_elevated_powershell_disables_profile")]
|
||||
#[cfg_attr(not(windows), test_case(PushedExecScenario::InterceptedPatch, false, false, true ; "foreign_windows_intercepted_patch_fails_closed"))]
|
||||
#[cfg_attr(not(windows), test_case(PushedExecScenario::UnsandboxedInterceptedPatch, false, false, true ; "foreign_windows_unsandboxed_intercepted_patch_succeeds"))]
|
||||
#[cfg_attr(not(windows), test_case(PushedExecScenario::FullDiskInterceptedPatch, false, false, true ; "foreign_windows_full_disk_intercepted_patch_succeeds"))]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn exec_command_consumes_pushed_remote_process_events(
|
||||
scenario: PushedExecScenario,
|
||||
managed_network: bool,
|
||||
policy_callbacks: bool,
|
||||
foreign_cwd: bool,
|
||||
) -> Result<()> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let server = start_mock_server().await;
|
||||
@@ -413,7 +485,16 @@ async fn exec_command_consumes_pushed_remote_process_events(
|
||||
CALL_ID,
|
||||
"exec_command",
|
||||
&json!({
|
||||
"cmd": "pwd",
|
||||
"cmd": match scenario {
|
||||
PushedExecScenario::InterceptedPatch => {
|
||||
"apply_patch <<'PATCH'\n*** Begin Patch\n*** Update File: secret.txt\n@@\n-old\n+new\n*** End Patch\nPATCH"
|
||||
}
|
||||
PushedExecScenario::UnsandboxedInterceptedPatch
|
||||
| PushedExecScenario::FullDiskInterceptedPatch => {
|
||||
"apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: allowed.txt\n+allowed\n*** End Patch\nPATCH"
|
||||
}
|
||||
_ => "pwd",
|
||||
},
|
||||
"yield_time_ms": 1_000,
|
||||
})
|
||||
.to_string(),
|
||||
@@ -481,6 +562,9 @@ timeout = 900
|
||||
let mut builder = builder.with_config(move |config| {
|
||||
config.project_doc_max_bytes = 0;
|
||||
config.use_experimental_unified_exec_tool = true;
|
||||
if matches!(scenario, PushedExecScenario::ElevatedPowerShell) {
|
||||
config.set_windows_elevated_sandbox_enabled(/*value*/ true);
|
||||
}
|
||||
if managed_network {
|
||||
config.approvals_reviewer = ApprovalsReviewer::AutoReview;
|
||||
config.bypass_hook_trust = true;
|
||||
@@ -496,6 +580,18 @@ timeout = 900
|
||||
|
||||
let turn_permission_profile = if managed_network {
|
||||
test.session_configured.permission_profile.clone()
|
||||
} else if matches!(scenario, PushedExecScenario::FullDiskInterceptedPatch) {
|
||||
PermissionProfile::from_runtime_permissions(
|
||||
&FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry::new(
|
||||
FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::Root,
|
||||
},
|
||||
FileSystemAccessMode::Write,
|
||||
)]),
|
||||
NetworkSandboxPolicy::Enabled,
|
||||
)
|
||||
} else if foreign_cwd && !matches!(scenario, PushedExecScenario::UnsandboxedInterceptedPatch) {
|
||||
PermissionProfile::workspace_write()
|
||||
} else {
|
||||
PermissionProfile::Disabled
|
||||
};
|
||||
@@ -511,6 +607,21 @@ timeout = 900
|
||||
responsesapi_client_metadata: None,
|
||||
additional_context: Default::default(),
|
||||
thread_settings: codex_protocol::protocol::ThreadSettingsOverrides {
|
||||
environments: foreign_cwd.then(|| {
|
||||
let cwd = PathUri::parse("file:///C:/workspace").expect("valid Windows cwd");
|
||||
TurnEnvironmentSelections::new(
|
||||
test.config.cwd.clone(),
|
||||
vec![TurnEnvironmentSelection {
|
||||
environment_id: codex_exec_server::REMOTE_ENVIRONMENT_ID.to_string(),
|
||||
cwd: cwd.clone(),
|
||||
workspace_roots: vec![
|
||||
cwd,
|
||||
PathUri::parse("file:///D:/other-workspace")
|
||||
.expect("valid Windows workspace root"),
|
||||
],
|
||||
}],
|
||||
)
|
||||
}),
|
||||
approval_policy: Some(if managed_network {
|
||||
AskForApproval::OnRequest
|
||||
} else {
|
||||
@@ -546,6 +657,64 @@ timeout = 900
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches!(scenario, PushedExecScenario::InterceptedPatch) {
|
||||
assert!(!saw_exec_command_begin);
|
||||
let request = response_mock
|
||||
.last_request()
|
||||
.context("model should receive the intercepted patch rejection")?;
|
||||
let (output, success) = request
|
||||
.function_call_output_content_and_success(CALL_ID)
|
||||
.context("intercepted patch rejection should be model visible")?;
|
||||
assert_ne!(success, Some(true));
|
||||
assert!(
|
||||
output
|
||||
.context("intercepted patch rejection should contain text")?
|
||||
.contains("cross-platform remote apply_patch is unavailable")
|
||||
);
|
||||
assert!(
|
||||
!exec_server.is_finished(),
|
||||
"intercepted patch must not access the executor filesystem"
|
||||
);
|
||||
exec_server.abort();
|
||||
return Ok(());
|
||||
}
|
||||
if matches!(
|
||||
scenario,
|
||||
PushedExecScenario::UnsandboxedInterceptedPatch
|
||||
| PushedExecScenario::FullDiskInterceptedPatch
|
||||
) {
|
||||
let request = response_mock
|
||||
.last_request()
|
||||
.context("model should receive the unrestricted patch result")?;
|
||||
let (output, success) = request
|
||||
.function_call_output_content_and_success(CALL_ID)
|
||||
.context("unrestricted patch result should be model visible")?;
|
||||
assert_ne!(success, Some(false));
|
||||
let output = output.context("unrestricted patch result should contain text")?;
|
||||
assert!(
|
||||
output.contains("Success. Updated the following files:"),
|
||||
"unrestricted intercepted patch failed: {output}"
|
||||
);
|
||||
let exec_server_result = timeout(Duration::from_secs(5), exec_server)
|
||||
.await
|
||||
.context("fake exec-server should observe the unrestricted patch write")??;
|
||||
let write_request = exec_server_result.process_start;
|
||||
assert_eq!(write_request["method"], "fs/writeFile");
|
||||
assert_eq!(
|
||||
write_request["params"]["path"],
|
||||
"file:///C:/workspace/allowed.txt"
|
||||
);
|
||||
assert_eq!(write_request["params"]["sandbox"], Value::Null);
|
||||
assert_eq!(
|
||||
BASE64_STANDARD.decode(
|
||||
write_request["params"]["dataBase64"]
|
||||
.as_str()
|
||||
.expect("filesystem write should include encoded contents")
|
||||
)?,
|
||||
b"allowed\n"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
let cleanup_timeout = if managed_network {
|
||||
Duration::from_secs(15)
|
||||
} else {
|
||||
@@ -554,6 +723,26 @@ timeout = 900
|
||||
let exec_server_result = timeout(cleanup_timeout, exec_server)
|
||||
.await
|
||||
.context("fake exec-server should observe process cleanup")??;
|
||||
if foreign_cwd {
|
||||
let params = &exec_server_result.process_start["params"];
|
||||
assert_eq!(params["cwd"], "file:///C:/workspace");
|
||||
assert_eq!(params["sandbox"]["cwd"], "file:///C:/workspace");
|
||||
assert_eq!(
|
||||
params["sandbox"]["workspaceRoots"],
|
||||
json!(["file:///C:/workspace", "file:///D:/other-workspace"])
|
||||
);
|
||||
if matches!(scenario, PushedExecScenario::ElevatedPowerShell) {
|
||||
assert_eq!(params["sandbox"]["windowsSandboxLevel"], "elevated");
|
||||
assert!(
|
||||
params["argv"]
|
||||
.as_array()
|
||||
.is_some_and(|argv| argv.iter().any(|arg| arg == "-NoProfile")),
|
||||
"elevated remote PowerShell must not load a user profile"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(params["sandbox"]["windowsSandboxLevel"], "restricted-token");
|
||||
}
|
||||
}
|
||||
if managed_network {
|
||||
let params = &exec_server_result.process_start["params"];
|
||||
assert_eq!(params["enforceManagedNetwork"], true);
|
||||
@@ -585,7 +774,7 @@ timeout = 900
|
||||
let output = output.context("exec_command output should contain text")?;
|
||||
let process_read_requests = exec_server_result.process_read_requests;
|
||||
match scenario {
|
||||
PushedExecScenario::Complete => {
|
||||
PushedExecScenario::Complete | PushedExecScenario::ElevatedPowerShell => {
|
||||
assert_ne!(success, Some(false));
|
||||
assert!(saw_exec_command_begin);
|
||||
assert!(output.contains("Process exited with code 0"));
|
||||
@@ -597,6 +786,11 @@ timeout = 900
|
||||
assert!(output.contains("Process exited with code 1"));
|
||||
assert_eq!(process_read_requests, 0, "unexpected compatibility read");
|
||||
}
|
||||
PushedExecScenario::InterceptedPatch => unreachable!("intercepted patch returned early"),
|
||||
PushedExecScenario::UnsandboxedInterceptedPatch
|
||||
| PushedExecScenario::FullDiskInterceptedPatch => {
|
||||
unreachable!("unsandboxed intercepted patch returned early")
|
||||
}
|
||||
PushedExecScenario::LegacyExit => {
|
||||
assert!(!saw_exec_command_begin);
|
||||
assert!(output.contains("Process exited with code 1"));
|
||||
|
||||
Reference in new issue
Block a user