mirror of
https://github.com/openai/codex.git
synced 2026-11-04 00:24:43 +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
@@ -25,6 +25,8 @@ use similar::TextDiff;
|
|||||||
pub use streaming_parser::StreamingPatchParser;
|
pub use streaming_parser::StreamingPatchParser;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
|
pub use invocation::MaybeApplyPatch;
|
||||||
|
pub use invocation::maybe_parse_apply_patch;
|
||||||
pub use invocation::maybe_parse_apply_patch_verified;
|
pub use invocation::maybe_parse_apply_patch_verified;
|
||||||
pub use invocation::verify_apply_patch_args;
|
pub use invocation::verify_apply_patch_args;
|
||||||
pub use standalone_executable::main;
|
pub use standalone_executable::main;
|
||||||
|
|||||||
@@ -152,23 +152,22 @@ impl ExecCommandHandler {
|
|||||||
let environment = Arc::clone(&turn_environment.environment);
|
let environment = Arc::clone(&turn_environment.environment);
|
||||||
let fs = environment.get_filesystem();
|
let fs = environment.get_filesystem();
|
||||||
|
|
||||||
// A foreign cwd cannot seed the AbsolutePathBufGuard used to resolve relative paths in the
|
// Remote executors enforce URI-native sandbox policy themselves. Only a host-local
|
||||||
// permissions config below. Consult the configured platform-sandbox requirement before
|
// sandbox needs a native cwd for resolving paths nested in the permissions config.
|
||||||
// deciding whether parsing may continue without that base path.
|
let requires_host_native_cwd = !environment.is_remote()
|
||||||
let sandbox = SandboxManager::new().select_initial(
|
&& SandboxManager::new().select_initial(
|
||||||
turn_environment.permission_profile(),
|
turn_environment.permission_profile(),
|
||||||
SandboxablePreference::Auto,
|
SandboxablePreference::Auto,
|
||||||
turn.windows_sandbox_level,
|
turn.windows_sandbox_level,
|
||||||
turn.network.is_some(),
|
turn.network.is_some(),
|
||||||
);
|
) != SandboxType::None;
|
||||||
// `to_abs_path()` alone cannot identify foreign drive paths: `file:///C:/repo` is
|
// `to_abs_path()` alone cannot identify foreign drive paths: `file:///C:/repo` is
|
||||||
// representable as `/C:/repo` on POSIX. Require the inferred convention to match too.
|
// representable as `/C:/repo` on POSIX. Require the inferred convention to match too.
|
||||||
let cwd_uses_native_convention =
|
let cwd_uses_native_convention =
|
||||||
cwd.infer_path_convention() == Some(PathConvention::native());
|
cwd.infer_path_convention() == Some(PathConvention::native());
|
||||||
// TODO(anp): Remove this parsing split once sandboxing supports foreign paths.
|
|
||||||
let native_cwd = match cwd.to_abs_path() {
|
let native_cwd = match cwd.to_abs_path() {
|
||||||
Ok(cwd) if cwd_uses_native_convention => Some(cwd),
|
Ok(cwd) if cwd_uses_native_convention => Some(cwd),
|
||||||
_ if sandbox == SandboxType::None => None,
|
_ if !requires_host_native_cwd => None,
|
||||||
Err(err) => return Err(FunctionCallError::RespondToModel(err.to_string())),
|
Err(err) => return Err(FunctionCallError::RespondToModel(err.to_string())),
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
return Err(FunctionCallError::RespondToModel(format!(
|
return Err(FunctionCallError::RespondToModel(format!(
|
||||||
@@ -183,9 +182,8 @@ impl ExecCommandHandler {
|
|||||||
parse_arguments_with_base_path(&arguments, native_cwd)?
|
parse_arguments_with_base_path(&arguments, native_cwd)?
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
// Parsing without a base only skips relative-path resolution inside the
|
// Foreign executor cwd values cannot seed this host's AbsolutePathBufGuard.
|
||||||
// permissions config. That is safe only for a truly unsandboxed attempt;
|
// Sandbox intent and URI-native roots are still sent to the executor.
|
||||||
// sandboxed attempts fall through and return the conversion error below.
|
|
||||||
parse_arguments(&arguments)?
|
parse_arguments(&arguments)?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -239,6 +237,23 @@ impl ExecCommandHandler {
|
|||||||
)
|
)
|
||||||
.map_err(FunctionCallError::RespondToModel)?;
|
.map_err(FunctionCallError::RespondToModel)?;
|
||||||
let command = resolved_command.command;
|
let command = resolved_command.command;
|
||||||
|
if environment.is_remote()
|
||||||
|
&& !cwd_uses_native_convention
|
||||||
|
&& !turn_environment
|
||||||
|
.permission_profile()
|
||||||
|
.file_system_sandbox_policy()
|
||||||
|
.has_full_disk_write_access()
|
||||||
|
&& matches!(
|
||||||
|
codex_apply_patch::maybe_parse_apply_patch(&command, &cwd),
|
||||||
|
codex_apply_patch::MaybeApplyPatch::Body(_)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
// CA-781: patch verification reads executor files before process sandboxing applies.
|
||||||
|
manager.release_process_id(process_id).await;
|
||||||
|
return Err(FunctionCallError::RespondToModel(
|
||||||
|
"cross-platform remote apply_patch is unavailable until executor-side filesystem sandboxing is supported".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
let shell_type = resolved_command.shell_type;
|
let shell_type = resolved_command.shell_type;
|
||||||
let command_for_display = codex_shell_command::parse_command::shlex_join(&command);
|
let command_for_display = codex_shell_command::parse_command::shlex_join(&command);
|
||||||
|
|
||||||
|
|||||||
@@ -151,8 +151,14 @@ impl ToolOrchestrator {
|
|||||||
|
|
||||||
let environment = tool.turn_environment(req);
|
let environment = tool.turn_environment(req);
|
||||||
let workspace_roots = environment.workspace_roots();
|
let workspace_roots = environment.workspace_roots();
|
||||||
|
let executor_managed_process_sandbox = tool.uses_executor_managed_process_sandbox(req);
|
||||||
let permission_profile = environment.permission_profile();
|
let permission_profile = environment.permission_profile();
|
||||||
let permissions = environment.permission_profile_with_workspace_roots();
|
let permissions = if executor_managed_process_sandbox {
|
||||||
|
// Executor-native roots remain symbolic until the executor applies its own sandbox.
|
||||||
|
permission_profile.clone()
|
||||||
|
} else {
|
||||||
|
environment.permission_profile_with_workspace_roots()
|
||||||
|
};
|
||||||
let file_system_sandbox_policy = permissions.file_system_sandbox_policy();
|
let file_system_sandbox_policy = permissions.file_system_sandbox_policy();
|
||||||
let requirement = tool.exec_approval_requirement(req).unwrap_or_else(|| {
|
let requirement = tool.exec_approval_requirement(req).unwrap_or_else(|| {
|
||||||
default_exec_approval_requirement(approval_policy, &file_system_sandbox_policy)
|
default_exec_approval_requirement(approval_policy, &file_system_sandbox_policy)
|
||||||
@@ -230,7 +236,7 @@ impl ToolOrchestrator {
|
|||||||
managed_network_active,
|
managed_network_active,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
let initial_sandbox = if sandbox_requested {
|
let initial_sandbox = if sandbox_requested && !executor_managed_process_sandbox {
|
||||||
self.sandbox.select_initial(
|
self.sandbox.select_initial(
|
||||||
&permissions,
|
&permissions,
|
||||||
sandbox_preference,
|
sandbox_preference,
|
||||||
@@ -415,7 +421,8 @@ impl ToolOrchestrator {
|
|||||||
sandbox_preference,
|
sandbox_preference,
|
||||||
managed_network_active,
|
managed_network_active,
|
||||||
);
|
);
|
||||||
let retry_sandbox = if retry_sandbox_requested {
|
let retry_sandbox = if retry_sandbox_requested && !executor_managed_process_sandbox
|
||||||
|
{
|
||||||
self.sandbox.select_initial(
|
self.sandbox.select_initial(
|
||||||
&permissions,
|
&permissions,
|
||||||
sandbox_preference,
|
sandbox_preference,
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ pub(crate) use codex_network_proxy::strip_managed_proxy_env;
|
|||||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||||
use codex_protocol::models::AdditionalPermissionProfile;
|
use codex_protocol::models::AdditionalPermissionProfile;
|
||||||
use codex_sandboxing::SandboxCommand;
|
use codex_sandboxing::SandboxCommand;
|
||||||
use codex_sandboxing::SandboxType;
|
|
||||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||||
use codex_utils_path_uri::PathUri;
|
use codex_utils_path_uri::PathUri;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -179,11 +178,11 @@ pub(crate) fn apply_zsh_fork_path_prepend(
|
|||||||
pub(crate) fn disable_powershell_profile_for_elevated_windows_sandbox(
|
pub(crate) fn disable_powershell_profile_for_elevated_windows_sandbox(
|
||||||
command: &[String],
|
command: &[String],
|
||||||
shell_type: Option<&ShellType>,
|
shell_type: Option<&ShellType>,
|
||||||
sandbox: SandboxType,
|
sandbox_requested: bool,
|
||||||
windows_sandbox_level: WindowsSandboxLevel,
|
windows_sandbox_level: WindowsSandboxLevel,
|
||||||
) -> Vec<String> {
|
) -> Vec<String> {
|
||||||
if shell_type != Some(&ShellType::PowerShell)
|
if shell_type != Some(&ShellType::PowerShell)
|
||||||
|| sandbox != SandboxType::WindowsRestrictedToken
|
|| !sandbox_requested
|
||||||
|| windows_sandbox_level != WindowsSandboxLevel::Elevated
|
|| windows_sandbox_level != WindowsSandboxLevel::Elevated
|
||||||
|| command.is_empty()
|
|| command.is_empty()
|
||||||
{
|
{
|
||||||
@@ -428,7 +427,7 @@ mod disable_powershell_profile_tests {
|
|||||||
let rewritten = disable_powershell_profile_for_elevated_windows_sandbox(
|
let rewritten = disable_powershell_profile_for_elevated_windows_sandbox(
|
||||||
&command,
|
&command,
|
||||||
Some(&ShellType::PowerShell),
|
Some(&ShellType::PowerShell),
|
||||||
SandboxType::WindowsRestrictedToken,
|
/*sandbox_requested*/ true,
|
||||||
WindowsSandboxLevel::Elevated,
|
WindowsSandboxLevel::Elevated,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -454,7 +453,7 @@ mod disable_powershell_profile_tests {
|
|||||||
let rewritten = disable_powershell_profile_for_elevated_windows_sandbox(
|
let rewritten = disable_powershell_profile_for_elevated_windows_sandbox(
|
||||||
&command,
|
&command,
|
||||||
Some(&ShellType::PowerShell),
|
Some(&ShellType::PowerShell),
|
||||||
SandboxType::WindowsRestrictedToken,
|
/*sandbox_requested*/ true,
|
||||||
WindowsSandboxLevel::Elevated,
|
WindowsSandboxLevel::Elevated,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -481,7 +480,7 @@ mod disable_powershell_profile_tests {
|
|||||||
let rewritten = disable_powershell_profile_for_elevated_windows_sandbox(
|
let rewritten = disable_powershell_profile_for_elevated_windows_sandbox(
|
||||||
&command,
|
&command,
|
||||||
Some(&ShellType::PowerShell),
|
Some(&ShellType::PowerShell),
|
||||||
SandboxType::WindowsRestrictedToken,
|
/*sandbox_requested*/ true,
|
||||||
WindowsSandboxLevel::Elevated,
|
WindowsSandboxLevel::Elevated,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -499,7 +498,7 @@ mod disable_powershell_profile_tests {
|
|||||||
let rewritten = disable_powershell_profile_for_elevated_windows_sandbox(
|
let rewritten = disable_powershell_profile_for_elevated_windows_sandbox(
|
||||||
&command,
|
&command,
|
||||||
Some(&ShellType::PowerShell),
|
Some(&ShellType::PowerShell),
|
||||||
SandboxType::WindowsRestrictedToken,
|
/*sandbox_requested*/ true,
|
||||||
WindowsSandboxLevel::RestrictedToken,
|
WindowsSandboxLevel::RestrictedToken,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -517,7 +516,7 @@ mod disable_powershell_profile_tests {
|
|||||||
let rewritten = disable_powershell_profile_for_elevated_windows_sandbox(
|
let rewritten = disable_powershell_profile_for_elevated_windows_sandbox(
|
||||||
&command,
|
&command,
|
||||||
Some(&ShellType::PowerShell),
|
Some(&ShellType::PowerShell),
|
||||||
SandboxType::None,
|
/*sandbox_requested*/ false,
|
||||||
WindowsSandboxLevel::Elevated,
|
WindowsSandboxLevel::Elevated,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -535,7 +534,7 @@ mod disable_powershell_profile_tests {
|
|||||||
let rewritten = disable_powershell_profile_for_elevated_windows_sandbox(
|
let rewritten = disable_powershell_profile_for_elevated_windows_sandbox(
|
||||||
&command,
|
&command,
|
||||||
Some(&ShellType::Bash),
|
Some(&ShellType::Bash),
|
||||||
SandboxType::WindowsRestrictedToken,
|
/*sandbox_requested*/ true,
|
||||||
WindowsSandboxLevel::Elevated,
|
WindowsSandboxLevel::Elevated,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
|
|||||||
let command = disable_powershell_profile_for_elevated_windows_sandbox(
|
let command = disable_powershell_profile_for_elevated_windows_sandbox(
|
||||||
&command,
|
&command,
|
||||||
req.shell_type.as_ref(),
|
req.shell_type.as_ref(),
|
||||||
attempt.sandbox,
|
attempt.sandbox_requested,
|
||||||
attempt.windows_sandbox_level,
|
attempt.windows_sandbox_level,
|
||||||
);
|
);
|
||||||
let command = if matches!(shell.shell_type, ShellType::PowerShell) {
|
let command = if matches!(shell.shell_type, ShellType::PowerShell) {
|
||||||
|
|||||||
@@ -192,6 +192,10 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
|
|||||||
&req.turn_environment
|
&req.turn_environment
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn uses_executor_managed_process_sandbox(&self, req: &UnifiedExecRequest) -> bool {
|
||||||
|
req.turn_environment.environment.is_remote()
|
||||||
|
}
|
||||||
|
|
||||||
fn sandbox_cwd<'b>(&self, req: &'b UnifiedExecRequest) -> Option<&'b PathUri> {
|
fn sandbox_cwd<'b>(&self, req: &'b UnifiedExecRequest) -> Option<&'b PathUri> {
|
||||||
Some(&req.sandbox_cwd)
|
Some(&req.sandbox_cwd)
|
||||||
}
|
}
|
||||||
@@ -363,7 +367,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
|
|||||||
let command = disable_powershell_profile_for_elevated_windows_sandbox(
|
let command = disable_powershell_profile_for_elevated_windows_sandbox(
|
||||||
&command,
|
&command,
|
||||||
Some(&req.shell_type),
|
Some(&req.shell_type),
|
||||||
attempt.sandbox,
|
attempt.sandbox_requested,
|
||||||
attempt.windows_sandbox_level,
|
attempt.windows_sandbox_level,
|
||||||
);
|
);
|
||||||
let command = if matches!(req.shell_type, ShellType::PowerShell) {
|
let command = if matches!(req.shell_type, ShellType::PowerShell) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use crate::tools::network_approval::NetworkApprovalSpec;
|
|||||||
use codex_file_system::FileSystemSandboxContext;
|
use codex_file_system::FileSystemSandboxContext;
|
||||||
use codex_network_proxy::NetworkProxy;
|
use codex_network_proxy::NetworkProxy;
|
||||||
use codex_protocol::approvals::ExecPolicyAmendment;
|
use codex_protocol::approvals::ExecPolicyAmendment;
|
||||||
|
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||||
use codex_protocol::error::CodexErr;
|
use codex_protocol::error::CodexErr;
|
||||||
use codex_protocol::permissions::FileSystemSandboxKind;
|
use codex_protocol::permissions::FileSystemSandboxKind;
|
||||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||||
@@ -27,6 +28,7 @@ use codex_sandboxing::SandboxType;
|
|||||||
use codex_sandboxing::SandboxablePreference;
|
use codex_sandboxing::SandboxablePreference;
|
||||||
use codex_sandboxing::policy_transforms::effective_permission_profile;
|
use codex_sandboxing::policy_transforms::effective_permission_profile;
|
||||||
use codex_tools::ToolName;
|
use codex_tools::ToolName;
|
||||||
|
use codex_utils_path_uri::PathConvention;
|
||||||
use codex_utils_path_uri::PathUri;
|
use codex_utils_path_uri::PathUri;
|
||||||
use futures::Future;
|
use futures::Future;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@@ -360,6 +362,10 @@ pub(crate) enum ToolError {
|
|||||||
pub(crate) trait ToolRuntime<Req, Out>: Approvable<Req> + Sandboxable {
|
pub(crate) trait ToolRuntime<Req, Out>: Approvable<Req> + Sandboxable {
|
||||||
fn turn_environment<'a>(&self, req: &'a Req) -> &'a TurnEnvironment;
|
fn turn_environment<'a>(&self, req: &'a Req) -> &'a TurnEnvironment;
|
||||||
|
|
||||||
|
fn uses_executor_managed_process_sandbox(&self, _req: &Req) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
fn network_approval_spec(&self, _req: &Req, _ctx: &ToolCtx) -> Option<NetworkApprovalSpec> {
|
fn network_approval_spec(&self, _req: &Req, _ctx: &ToolCtx) -> Option<NetworkApprovalSpec> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -472,11 +478,24 @@ impl<'a> SandboxAttempt<'a> {
|
|||||||
crate::sandboxing::ExecRequest::from_sandbox_exec_request(request, options, Vec::new());
|
crate::sandboxing::ExecRequest::from_sandbox_exec_request(request, options, Vec::new());
|
||||||
exec_request.exec_server_managed_network = managed_network;
|
exec_request.exec_server_managed_network = managed_network;
|
||||||
if self.sandbox_requested {
|
if self.sandbox_requested {
|
||||||
|
// This level comes from the orchestrator's config, so `Disabled` means Windows
|
||||||
|
// sandboxing is irrelevant on a non-Windows host. A Windows executor would instead
|
||||||
|
// treat it as unable to enforce the requested sandbox. Select its baseline restricted
|
||||||
|
// token backend while preserving explicitly configured levels and same-OS behavior.
|
||||||
|
let windows_sandbox_level = if self.windows_sandbox_level
|
||||||
|
== WindowsSandboxLevel::Disabled
|
||||||
|
&& self.sandbox_cwd.infer_path_convention() == Some(PathConvention::Windows)
|
||||||
|
&& PathConvention::native() != PathConvention::Windows
|
||||||
|
{
|
||||||
|
WindowsSandboxLevel::RestrictedToken
|
||||||
|
} else {
|
||||||
|
self.windows_sandbox_level
|
||||||
|
};
|
||||||
exec_request.exec_server_sandbox = Some(FileSystemSandboxContext {
|
exec_request.exec_server_sandbox = Some(FileSystemSandboxContext {
|
||||||
permissions: exec_server_permissions.into(),
|
permissions: exec_server_permissions.into(),
|
||||||
cwd: Some(exec_request.windows_sandbox_policy_cwd.clone()),
|
cwd: Some(exec_request.windows_sandbox_policy_cwd.clone()),
|
||||||
workspace_roots: self.workspace_roots.to_vec(),
|
workspace_roots: self.workspace_roots.to_vec(),
|
||||||
windows_sandbox_level: self.windows_sandbox_level,
|
windows_sandbox_level,
|
||||||
windows_sandbox_private_desktop: self.windows_sandbox_private_desktop,
|
windows_sandbox_private_desktop: self.windows_sandbox_private_desktop,
|
||||||
windows_sandbox_proxy_settings_mode: None,
|
windows_sandbox_proxy_settings_mode: None,
|
||||||
use_legacy_landlock: self.use_legacy_landlock,
|
use_legacy_landlock: self.use_legacy_landlock,
|
||||||
|
|||||||
@@ -6,10 +6,19 @@ use codex_config::test_support::CloudConfigBundleFixture;
|
|||||||
use codex_features::Feature;
|
use codex_features::Feature;
|
||||||
use codex_protocol::config_types::ApprovalsReviewer;
|
use codex_protocol::config_types::ApprovalsReviewer;
|
||||||
use codex_protocol::models::PermissionProfile;
|
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::AskForApproval;
|
||||||
use codex_protocol::protocol::EventMsg;
|
use codex_protocol::protocol::EventMsg;
|
||||||
use codex_protocol::protocol::Op;
|
use codex_protocol::protocol::Op;
|
||||||
|
use codex_protocol::protocol::TurnEnvironmentSelection;
|
||||||
|
use codex_protocol::protocol::TurnEnvironmentSelections;
|
||||||
use codex_protocol::user_input::UserInput;
|
use codex_protocol::user_input::UserInput;
|
||||||
|
use codex_utils_path_uri::PathUri;
|
||||||
use core_test_support::managed_network_requirements_loader;
|
use core_test_support::managed_network_requirements_loader;
|
||||||
use core_test_support::responses::ev_assistant_message;
|
use core_test_support::responses::ev_assistant_message;
|
||||||
use core_test_support::responses::ev_completed;
|
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;
|
const REPLAY_RETAINED_OUTPUT_SEQ: u64 = 800;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
#[cfg_attr(windows, allow(dead_code))]
|
||||||
enum PushedExecScenario {
|
enum PushedExecScenario {
|
||||||
Complete,
|
Complete,
|
||||||
DirectDenied,
|
DirectDenied,
|
||||||
|
ElevatedPowerShell,
|
||||||
|
InterceptedPatch,
|
||||||
|
UnsandboxedInterceptedPatch,
|
||||||
|
FullDiskInterceptedPatch,
|
||||||
LegacyExit,
|
LegacyExit,
|
||||||
ReplayGap,
|
ReplayGap,
|
||||||
}
|
}
|
||||||
@@ -103,19 +117,31 @@ async fn accept_initialized_exec_server(listener: TcpListener) -> WebSocketStrea
|
|||||||
websocket
|
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;
|
let info = read_exec_server_json(websocket).await;
|
||||||
assert_eq!(info["method"], "environment/info");
|
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(
|
send_exec_server_json(
|
||||||
websocket,
|
websocket,
|
||||||
json!({
|
json!({
|
||||||
"id": id,
|
"id": id,
|
||||||
"result": {
|
"result": {
|
||||||
"shell": { "name": "zsh", "path": "/bin/zsh" },
|
"shell": shell,
|
||||||
"capabilities": { "networkProxyLaunch": true }
|
"capabilities": { "networkProxyLaunch": true }
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -127,15 +153,20 @@ async fn serve_exec_with_pushed_events(
|
|||||||
listener: TcpListener,
|
listener: TcpListener,
|
||||||
scenario: PushedExecScenario,
|
scenario: PushedExecScenario,
|
||||||
) -> PushedExecServerResult {
|
) -> PushedExecServerResult {
|
||||||
|
let unrestricted_patch = matches!(
|
||||||
|
scenario,
|
||||||
|
PushedExecScenario::UnsandboxedInterceptedPatch
|
||||||
|
| PushedExecScenario::FullDiskInterceptedPatch
|
||||||
|
);
|
||||||
let mut websocket = accept_initialized_exec_server(listener).await;
|
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 process_start = loop {
|
||||||
let request = read_exec_server_json(&mut websocket).await;
|
let request = read_exec_server_json(&mut websocket).await;
|
||||||
match request["method"].as_str() {
|
match request["method"].as_str() {
|
||||||
Some("process/start") => break request,
|
Some("process/start") => break request,
|
||||||
Some("environment/info") => {
|
Some("environment/info") => {
|
||||||
respond_environment_info(&mut websocket, &request["id"]).await;
|
respond_environment_info(&mut websocket, &request["id"], scenario).await;
|
||||||
}
|
}
|
||||||
Some("fs/getMetadata") => {
|
Some("fs/getMetadata") => {
|
||||||
send_exec_server_json(
|
send_exec_server_json(
|
||||||
@@ -147,6 +178,24 @@ async fn serve_exec_with_pushed_events(
|
|||||||
)
|
)
|
||||||
.await;
|
.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") => {
|
Some("fs/canonicalize") => {
|
||||||
send_exec_server_json(
|
send_exec_server_json(
|
||||||
&mut websocket,
|
&mut websocket,
|
||||||
@@ -229,7 +278,7 @@ async fn serve_exec_with_pushed_events(
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
match scenario {
|
match scenario {
|
||||||
PushedExecScenario::Complete => {
|
PushedExecScenario::Complete | PushedExecScenario::ElevatedPowerShell => {
|
||||||
let encoded_output = BASE64_STANDARD.encode(COMPLETE_OUTPUT);
|
let encoded_output = BASE64_STANDARD.encode(COMPLETE_OUTPUT);
|
||||||
for message in [
|
for message in [
|
||||||
json!({
|
json!({
|
||||||
@@ -273,6 +322,13 @@ async fn serve_exec_with_pushed_events(
|
|||||||
)
|
)
|
||||||
.await;
|
.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 => {
|
PushedExecScenario::LegacyExit => {
|
||||||
send_exec_server_json(
|
send_exec_server_json(
|
||||||
&mut websocket,
|
&mut websocket,
|
||||||
@@ -319,6 +375,16 @@ async fn serve_exec_with_pushed_events(
|
|||||||
"failure": null,
|
"failure": null,
|
||||||
"sandboxDenied": true,
|
"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!({
|
PushedExecScenario::LegacyExit => json!({
|
||||||
"chunks": [],
|
"chunks": [],
|
||||||
"nextSeq": 3,
|
"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::Complete, false, false, false ; "complete_event_stream")]
|
||||||
#[test_case(PushedExecScenario::DirectDenied, false, false ; "direct_sandbox_denial")]
|
#[test_case(PushedExecScenario::DirectDenied, false, false, false ; "direct_sandbox_denial")]
|
||||||
#[test_case(PushedExecScenario::LegacyExit, false, false ; "legacy_exit_metadata")]
|
#[test_case(PushedExecScenario::LegacyExit, false, false, false ; "legacy_exit_metadata")]
|
||||||
#[test_case(PushedExecScenario::ReplayGap, false, false ; "truncated_event_replay")]
|
#[test_case(PushedExecScenario::ReplayGap, false, false, false ; "truncated_event_replay")]
|
||||||
#[test_case(PushedExecScenario::Complete, true, true ; "managed_network_uses_executor_proxy_launch")]
|
#[test_case(PushedExecScenario::Complete, true, true, false ; "managed_network_uses_executor_proxy_launch")]
|
||||||
#[test_case(PushedExecScenario::Complete, true, false ; "strict_managed_allowlist_omits_policy_callbacks")]
|
#[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)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
async fn exec_command_consumes_pushed_remote_process_events(
|
async fn exec_command_consumes_pushed_remote_process_events(
|
||||||
scenario: PushedExecScenario,
|
scenario: PushedExecScenario,
|
||||||
managed_network: bool,
|
managed_network: bool,
|
||||||
policy_callbacks: bool,
|
policy_callbacks: bool,
|
||||||
|
foreign_cwd: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||||
let server = start_mock_server().await;
|
let server = start_mock_server().await;
|
||||||
@@ -413,7 +485,16 @@ async fn exec_command_consumes_pushed_remote_process_events(
|
|||||||
CALL_ID,
|
CALL_ID,
|
||||||
"exec_command",
|
"exec_command",
|
||||||
&json!({
|
&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,
|
"yield_time_ms": 1_000,
|
||||||
})
|
})
|
||||||
.to_string(),
|
.to_string(),
|
||||||
@@ -481,6 +562,9 @@ timeout = 900
|
|||||||
let mut builder = builder.with_config(move |config| {
|
let mut builder = builder.with_config(move |config| {
|
||||||
config.project_doc_max_bytes = 0;
|
config.project_doc_max_bytes = 0;
|
||||||
config.use_experimental_unified_exec_tool = true;
|
config.use_experimental_unified_exec_tool = true;
|
||||||
|
if matches!(scenario, PushedExecScenario::ElevatedPowerShell) {
|
||||||
|
config.set_windows_elevated_sandbox_enabled(/*value*/ true);
|
||||||
|
}
|
||||||
if managed_network {
|
if managed_network {
|
||||||
config.approvals_reviewer = ApprovalsReviewer::AutoReview;
|
config.approvals_reviewer = ApprovalsReviewer::AutoReview;
|
||||||
config.bypass_hook_trust = true;
|
config.bypass_hook_trust = true;
|
||||||
@@ -496,6 +580,18 @@ timeout = 900
|
|||||||
|
|
||||||
let turn_permission_profile = if managed_network {
|
let turn_permission_profile = if managed_network {
|
||||||
test.session_configured.permission_profile.clone()
|
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 {
|
} else {
|
||||||
PermissionProfile::Disabled
|
PermissionProfile::Disabled
|
||||||
};
|
};
|
||||||
@@ -511,6 +607,21 @@ timeout = 900
|
|||||||
responsesapi_client_metadata: None,
|
responsesapi_client_metadata: None,
|
||||||
additional_context: Default::default(),
|
additional_context: Default::default(),
|
||||||
thread_settings: codex_protocol::protocol::ThreadSettingsOverrides {
|
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 {
|
approval_policy: Some(if managed_network {
|
||||||
AskForApproval::OnRequest
|
AskForApproval::OnRequest
|
||||||
} else {
|
} 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 {
|
let cleanup_timeout = if managed_network {
|
||||||
Duration::from_secs(15)
|
Duration::from_secs(15)
|
||||||
} else {
|
} else {
|
||||||
@@ -554,6 +723,26 @@ timeout = 900
|
|||||||
let exec_server_result = timeout(cleanup_timeout, exec_server)
|
let exec_server_result = timeout(cleanup_timeout, exec_server)
|
||||||
.await
|
.await
|
||||||
.context("fake exec-server should observe process cleanup")??;
|
.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 {
|
if managed_network {
|
||||||
let params = &exec_server_result.process_start["params"];
|
let params = &exec_server_result.process_start["params"];
|
||||||
assert_eq!(params["enforceManagedNetwork"], true);
|
assert_eq!(params["enforceManagedNetwork"], true);
|
||||||
@@ -585,7 +774,7 @@ timeout = 900
|
|||||||
let output = output.context("exec_command output should contain text")?;
|
let output = output.context("exec_command output should contain text")?;
|
||||||
let process_read_requests = exec_server_result.process_read_requests;
|
let process_read_requests = exec_server_result.process_read_requests;
|
||||||
match scenario {
|
match scenario {
|
||||||
PushedExecScenario::Complete => {
|
PushedExecScenario::Complete | PushedExecScenario::ElevatedPowerShell => {
|
||||||
assert_ne!(success, Some(false));
|
assert_ne!(success, Some(false));
|
||||||
assert!(saw_exec_command_begin);
|
assert!(saw_exec_command_begin);
|
||||||
assert!(output.contains("Process exited with code 0"));
|
assert!(output.contains("Process exited with code 0"));
|
||||||
@@ -597,6 +786,11 @@ timeout = 900
|
|||||||
assert!(output.contains("Process exited with code 1"));
|
assert!(output.contains("Process exited with code 1"));
|
||||||
assert_eq!(process_read_requests, 0, "unexpected compatibility read");
|
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 => {
|
PushedExecScenario::LegacyExit => {
|
||||||
assert!(!saw_exec_command_begin);
|
assert!(!saw_exec_command_begin);
|
||||||
assert!(output.contains("Process exited with code 1"));
|
assert!(output.contains("Process exited with code 1"));
|
||||||
|
|||||||
Reference in new issue
Block a user