From 942af8447b1d1addc81fb3c3135293497d479a06 Mon Sep 17 00:00:00 2001 From: jif Date: Wed, 19 Aug 2026 22:57:42 +0000 Subject: [PATCH] Retire the untrusted approval policy (#39630) ## What changed - Remove `untrusted` from the CLI, configuration schema, and MCP tool interface. Explicit `approval_policy = "untrusted"` settings now fail with an actionable error. - Remove the known-safe command allowlist. Projects marked untrusted now request approval for every command unless an explicit exec policy rule allows it. - Keep command parsing conservative by treating in-place `sed` forms as mutating and ignoring unrecognized commands when recording memory usage. ## Testing - Cover rejection of the retired configuration value and approval requests for commands in untrusted projects. GitOrigin-RevId: d6bf425edddfffbb325eee6acf383434af5fd33b --- .../app-server-exports-stable.json.zst | Bin 135276 -> 135239 bytes .../app-server/src/config_manager_service.rs | 10 +- codex-rs/app-server/src/lib.rs | 13 +- codex-rs/app-server/tests/common/lib.rs | 1 + codex-rs/app-server/tests/common/responses.rs | 21 + .../app-server/tests/suite/v2/mcp_resource.rs | 2 +- .../tests/suite/v2/mcp_server_elicitation.rs | 2 +- .../tests/suite/v2/request_permissions.rs | 2 +- .../tests/suite/v2/request_user_input.rs | 2 +- codex-rs/app-server/tests/suite/v2/review.rs | 2 +- .../app-server/tests/suite/v2/thread_queue.rs | 6 +- .../tests/suite/v2/thread_shell_command.rs | 6 +- .../tests/suite/v2/thread_status.rs | 4 +- .../tests/suite/v2/turn_interrupt.rs | 2 +- .../app-server/tests/suite/v2/turn_start.rs | 44 +- .../tests/suite/v2/turn_start_zsh_fork.rs | 11 +- codex-rs/config/src/config_requirements.rs | 18 +- codex-rs/config/src/config_toml.rs | 1 + codex-rs/config/src/profile_toml.rs | 1 + codex-rs/config/src/schema.rs | 24 + codex-rs/core/config.schema.json | 7 - codex-rs/core/src/config/config_tests.rs | 25 +- codex-rs/core/src/config/mod.rs | 11 + codex-rs/core/src/exec_policy.rs | 24 +- .../core/src/exec_policy_windows_tests.rs | 4 +- codex-rs/core/src/session/tests.rs | 106 ---- .../core/src/tools/handlers/shell_tests.rs | 46 -- .../tools/runtimes/shell/unix_escalation.rs | 2 +- codex-rs/core/tests/suite/approvals.rs | 55 +- codex-rs/mcp-server/src/codex_tool_config.rs | 7 +- codex-rs/mcp-server/tests/common/responses.rs | 2 + codex-rs/mcp-server/tests/suite/codex_tool.rs | 46 +- codex-rs/memories/read/src/usage.rs | 12 +- .../approval_policy/unless_trusted.md | 2 +- codex-rs/protocol/src/protocol.rs | 5 +- codex-rs/shell-command/src/bash.rs | 4 +- .../src/command_safety/is_safe_command.rs | 536 ------------------ .../shell-command/src/command_safety/mod.rs | 3 - .../command_safety/windows_safe_commands.rs | 503 ---------------- codex-rs/shell-command/src/lib.rs | 1 - codex-rs/shell-command/src/parse_command.rs | 14 +- codex-rs/shell-command/src/powershell.rs | 5 +- .../utils/cli/src/approval_mode_cli_arg.rs | 6 - 43 files changed, 218 insertions(+), 1380 deletions(-) delete mode 100644 codex-rs/shell-command/src/command_safety/is_safe_command.rs delete mode 100644 codex-rs/shell-command/src/command_safety/windows_safe_commands.rs diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index 7b93fdf70a792bf1da375f42b5db9f0958e652c8..b5554016a2b96e2976577031a90750ef45f17d9a 100644 GIT binary patch delta 17157 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%0AP9mY49$QFdq=GSaQQt(PoH{H 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 M{;vliVv2ht0m@JMMgRZ+ delta 17196 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

1t36eENl+`7#9_(87_zj|Gx}WadBTCis^w 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 diff --git a/codex-rs/app-server/src/config_manager_service.rs b/codex-rs/app-server/src/config_manager_service.rs index a222d3d598..63b6ed7775 100644 --- a/codex-rs/app-server/src/config_manager_service.rs +++ b/codex-rs/app-server/src/config_manager_service.rs @@ -30,6 +30,7 @@ use codex_core::path_utils; use codex_core::path_utils::SymlinkWritePaths; use codex_core::path_utils::resolve_symlink_write_paths; use codex_core::path_utils::write_atomically; +use codex_protocol::protocol::AskForApproval; use codex_utils_absolute_path::AbsolutePathBuf; use serde_json::Value as JsonValue; use std::borrow::Cow; @@ -734,8 +735,13 @@ fn toml_value_to_value(value: &TomlValue) -> anyhow::Result { } } -fn validate_config(value: &TomlValue) -> Result<(), toml::de::Error> { - let _: ConfigToml = value.clone().try_into()?; +fn validate_config(value: &TomlValue) -> anyhow::Result<()> { + let config: ConfigToml = value.clone().try_into()?; + if config.approval_policy == Some(AskForApproval::UnlessTrusted) { + anyhow::bail!( + "approval_policy = \"untrusted\" is no longer supported; remove this setting" + ); + } Ok(()) } diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 26a8859c18..b0f5062389 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -8,6 +8,7 @@ use codex_code_mode::WebSocketCodeModeSessionProvider; use codex_config::LoaderOverrides; use codex_config::NoopThreadConfigLoader; use codex_core::config::Config; +use codex_core::config::UnsupportedUntrustedApprovalPolicyError; use codex_core::resolve_installation_id; use codex_login::AuthManager; #[cfg(debug_assertions)] @@ -504,11 +505,19 @@ pub async fn run_main_with_transport_options( config.http_client_factory(), ); } + Err(err) + if err.get_ref().is_some_and( + ::is::< + UnsupportedUntrustedApprovalPolicyError, + >, + ) => + { + return Err(err); + } Err(err) => { warn!(error = %err, "Failed to preload config for cloud config bundle"); - // TODO: Decide whether bootstrap config preload failures should block startup. // If this fails, we cannot install cloud/thread config loaders, so non-strict - // startup may continue without managed cloud config. + // startup continues without managed cloud config. } }; let mut config_warnings = Vec::new(); diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index ad6d99ae5c..538274ee54 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -37,6 +37,7 @@ pub use mock_model_server::create_mock_responses_server_sequence_unchecked; pub use models_cache::write_models_cache; pub use models_cache::write_models_cache_with_models; pub use responses::create_apply_patch_sse_response; +pub use responses::create_escalated_shell_command_sse_response; pub use responses::create_exec_command_sse_response; pub use responses::create_final_assistant_message_sse_response; pub use responses::create_request_permissions_sse_response; diff --git a/codex-rs/app-server/tests/common/responses.rs b/codex-rs/app-server/tests/common/responses.rs index 586d1446c0..a862259b06 100644 --- a/codex-rs/app-server/tests/common/responses.rs +++ b/codex-rs/app-server/tests/common/responses.rs @@ -22,6 +22,27 @@ pub fn create_shell_command_sse_response( ])) } +pub fn create_escalated_shell_command_sse_response( + command: Vec, + workdir: Option<&Path>, + timeout_ms: Option, + call_id: &str, +) -> anyhow::Result { + let command_str = shlex::try_join(command.iter().map(String::as_str))?; + let tool_call_arguments = serde_json::to_string(&json!({ + "command": command_str, + "workdir": workdir.map(|w| w.to_string_lossy()), + "timeout_ms": timeout_ms, + "sandbox_permissions": "require_escalated", + "justification": "Test approval request." + }))?; + Ok(responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_function_call(call_id, "shell_command", &tool_call_arguments), + responses::ev_completed("resp-1"), + ])) +} + pub fn create_final_assistant_message_sse_response(message: &str) -> anyhow::Result { Ok(responses::sse(vec![ responses::ev_response_created("resp-1"), diff --git a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs index fcbe5ed9ef..2ccc6ddbee 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs @@ -720,7 +720,7 @@ async fn start_resource_test_app_server_with_extra_config( ) -> Result<(TempDir, TestAppServer)> { let codex_home = TempDir::new()?; MockResponsesConfig::new(responses_server_uri) - .with_approval_policy("untrusted") + .with_approval_policy("on-request") .with_root_config(&format!( "chatgpt_base_url = \"{apps_server_url}\"\nmcp_oauth_credentials_store = \"file\"" )) diff --git a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs index 8dfefccc11..51821100fb 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs @@ -1354,7 +1354,7 @@ fn write_config_toml( format!( r#" model = "mock-model" -approval_policy = "untrusted" +approval_policy = "on-request" sandbox_mode = "read-only" model_provider = "mock_provider" diff --git a/codex-rs/app-server/tests/suite/v2/request_permissions.rs b/codex-rs/app-server/tests/suite/v2/request_permissions.rs index 787989b6d8..93cb18cae7 100644 --- a/codex-rs/app-server/tests/suite/v2/request_permissions.rs +++ b/codex-rs/app-server/tests/suite/v2/request_permissions.rs @@ -36,7 +36,7 @@ async fn request_permissions_round_trip() -> Result<()> { ]; let server = create_mock_responses_server_sequence(responses).await; MockResponsesConfig::new(&server.uri()) - .with_approval_policy("untrusted") + .with_approval_policy("on-request") .enable_feature(Feature::RequestPermissionsTool) .write(codex_home.path())?; diff --git a/codex-rs/app-server/tests/suite/v2/request_user_input.rs b/codex-rs/app-server/tests/suite/v2/request_user_input.rs index 8daa098a33..bd52ef2fcb 100644 --- a/codex-rs/app-server/tests/suite/v2/request_user_input.rs +++ b/codex-rs/app-server/tests/suite/v2/request_user_input.rs @@ -77,7 +77,7 @@ async fn request_user_input_round_trip_for_mode( ]; let server = create_mock_responses_server_sequence(responses).await; MockResponsesConfig::new(&server.uri()) - .with_approval_policy("untrusted") + .with_approval_policy("on-request") .write(codex_home.path())?; let mut mcp = TestAppServer::builder() diff --git a/codex-rs/app-server/tests/suite/v2/review.rs b/codex-rs/app-server/tests/suite/v2/review.rs index 48af99641a..070e441563 100644 --- a/codex-rs/app-server/tests/suite/v2/review.rs +++ b/codex-rs/app-server/tests/suite/v2/review.rs @@ -212,7 +212,7 @@ async fn review_start_exec_approval_item_id_matches_command_execution_item() -> let codex_home = TempDir::new()?; MockResponsesConfig::new(&server.uri()) .with_provider_name("Mock provider") - .with_approval_policy("untrusted") + .with_approval_policy("on-request") .disable_feature(Feature::ShellSnapshot) .write(codex_home.path())?; diff --git a/codex-rs/app-server/tests/suite/v2/thread_queue.rs b/codex-rs/app-server/tests/suite/v2/thread_queue.rs index 97adbf789b..67291b0255 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_queue.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_queue.rs @@ -4,10 +4,10 @@ use anyhow::Context; use anyhow::Result; use app_test_support::MockResponsesConfig; use app_test_support::TestAppServer; +use app_test_support::create_escalated_shell_command_sse_response; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; -use app_test_support::create_shell_command_sse_response; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::CommandExecutionApprovalDecision; @@ -863,7 +863,7 @@ async fn queue_app(responses: Vec) -> Result<(TestAppServer, TempDir, Mo async fn queue_app_with_server(server: MockServer) -> Result<(TestAppServer, TempDir, MockServer)> { let codex_home = TempDir::new()?; let config = MockResponsesConfig::new(&server.uri()) - .with_approval_policy("untrusted") + .with_approval_policy("on-request") .with_root_config(r#"approvals_reviewer = "user""#); config.write(codex_home.path())?; let app = TestAppServer::builder() @@ -888,7 +888,7 @@ fn blocked_turn_response() -> Result { "import time; time.sleep(10)".to_string(), ]; - create_shell_command_sse_response( + create_escalated_shell_command_sse_response( shell_command, /*workdir*/ None, /*timeout_ms*/ Some(10_000), diff --git a/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs b/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs index 5d9ef748a7..891201d644 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs @@ -1,9 +1,9 @@ use anyhow::Result; use app_test_support::MockResponsesConfig; use app_test_support::TestAppServer; +use app_test_support::create_escalated_shell_command_sse_response; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_responses_server_sequence; -use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::CommandExecutionApprovalDecision; @@ -202,7 +202,7 @@ async fn thread_shell_command_uses_existing_active_turn() -> Result<()> { std::fs::create_dir(&workspace)?; let responses = vec![ - create_shell_command_sse_response( + create_escalated_shell_command_sse_response( vec![ "python3".to_string(), "-c".to_string(), @@ -216,7 +216,7 @@ async fn thread_shell_command_uses_existing_active_turn() -> Result<()> { ]; let server = create_mock_responses_server_sequence(responses).await; MockResponsesConfig::new(&server.uri()) - .with_approval_policy("untrusted") + .with_approval_policy("on-request") .write(&codex_home)?; let mut mcp = TestAppServer::builder() diff --git a/codex-rs/app-server/tests/suite/v2/thread_status.rs b/codex-rs/app-server/tests/suite/v2/thread_status.rs index bf2db5c0f7..9c51958f97 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_status.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_status.rs @@ -27,7 +27,7 @@ async fn thread_status_changed_emits_runtime_updates() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("done")?]; let server = create_mock_responses_server_sequence(responses).await; MockResponsesConfig::new(&server.uri()) - .with_approval_policy("untrusted") + .with_approval_policy("on-request") .enable_feature(Feature::CollaborationModes) .write(codex_home.path())?; @@ -131,7 +131,7 @@ async fn thread_status_changed_can_be_opted_out() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("done")?]; let server = create_mock_responses_server_sequence(responses).await; MockResponsesConfig::new(&server.uri()) - .with_approval_policy("untrusted") + .with_approval_policy("on-request") .enable_feature(Feature::CollaborationModes) .write(codex_home.path())?; diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 6caf01d203..4e3225e7a4 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -226,7 +226,7 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() )?]) .await; MockResponsesConfig::new(&server.uri()) - .with_approval_policy("untrusted") + .with_approval_policy("on-request") .with_root_config(r#"approvals_reviewer = "user""#) .write(&codex_home)?; diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index cd107bf668..52a05a5925 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -3,6 +3,7 @@ use anyhow::Result; use app_test_support::MockResponsesConfig; use app_test_support::TestAppServer; use app_test_support::create_apply_patch_sse_response; +use app_test_support::create_escalated_shell_command_sse_response; use app_test_support::create_exec_command_sse_response; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_responses_server_repeating_assistant; @@ -2253,7 +2254,7 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> { // Mock server: first turn requests a shell call (elicitation), then completes. // Second turn same, but we'll set approval_policy=never to avoid elicitation. let responses = vec![ - create_shell_command_sse_response( + create_escalated_shell_command_sse_response( first_shell_command, /*workdir*/ None, Some(5000), @@ -2273,9 +2274,9 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> { create_final_assistant_message_sse_response("done 2")?, ]; let server = create_mock_responses_server_sequence(responses).await; - // Default approval is untrusted to force elicitation on first turn. + // The first tool call explicitly requests escalation to force elicitation. MockResponsesConfig::new(&server.uri()) - .with_approval_policy("untrusted") + .with_approval_policy("on-request") .write(codex_home.as_path())?; let mut mcp = TestAppServer::builder() @@ -2467,7 +2468,7 @@ async fn run_turn_start_exec_approval_rejection_v2( expected_approval_command.replace(bearer_token, "[REDACTED_SECRET]"); let responses = vec![ - create_shell_command_sse_response( + create_escalated_shell_command_sse_response( shell_command, /*workdir*/ None, Some(5000), @@ -2477,7 +2478,7 @@ async fn run_turn_start_exec_approval_rejection_v2( ]; let server = create_mock_responses_server_sequence(responses).await; MockResponsesConfig::new(&server.uri()) - .with_approval_policy("untrusted") + .with_approval_policy("on-request") .write(codex_home.as_path())?; let mut mcp = TestAppServer::builder() @@ -2638,7 +2639,7 @@ async fn turn_start_explicit_local_environment_updates_legacy_cwd_between_turns( ]; let server = create_mock_responses_server_sequence(responses).await; MockResponsesConfig::new(&server.uri()) - .with_approval_policy("untrusted") + .with_approval_policy("on-request") .write(&codex_home)?; let mut mcp = TestAppServer::builder() @@ -3077,7 +3078,8 @@ async fn turn_start_file_change_approval_v2() -> Result<()> { ]; let server = create_mock_responses_server_sequence_unchecked(responses).await; MockResponsesConfig::new(&server.uri()) - .with_approval_policy("untrusted") + .with_approval_policy("on-request") + .with_sandbox_mode("read-only") // Snapshot startup is unrelated to the file-approval behavior under test. .disable_feature(Feature::ShellSnapshot) .write(&codex_home)?; @@ -4048,7 +4050,8 @@ async fn turn_start_file_change_approval_accept_for_session_persists_v2() -> Res ]; let server = create_mock_responses_server_sequence(responses).await; MockResponsesConfig::new(&server.uri()) - .with_approval_policy("untrusted") + .with_approval_policy("on-request") + .with_sandbox_mode("read-only") .write(&codex_home)?; let mut mcp = TestAppServer::builder() @@ -4276,7 +4279,8 @@ async fn run_turn_start_file_change_approval_rejection_v2( ]; let server = create_mock_responses_server_sequence(responses).await; MockResponsesConfig::new(&server.uri()) - .with_approval_policy("untrusted") + .with_approval_policy("on-request") + .with_sandbox_mode("read-only") .write(&codex_home)?; let mut mcp = TestAppServer::builder() @@ -4568,7 +4572,7 @@ async fn command_execution_notifications_include_trusted_plugin_id() -> Result<( ]; let server = create_mock_responses_server_sequence(responses).await; MockResponsesConfig::new(&server.uri()) - .with_approval_policy("untrusted") + .with_approval_policy("on-request") .with_sandbox_mode("danger-full-access") .enable_feature(Feature::Plugins) .disable_feature(Feature::RemotePlugin) @@ -4630,25 +4634,9 @@ async fn command_execution_notifications_include_trusted_plugin_id() -> Result<( }) .await??; if method == "item/started" { - let server_req = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_request_message(), - ) - .await??; - let ServerRequest::CommandExecutionRequestApproval { request_id, params } = server_req - else { - panic!("expected CommandExecutionRequestApproval request"); - }; - assert_eq!(params.item_id, "plugin-command"); - mcp.send_response( - request_id, - serde_json::to_value(CommandExecutionRequestApprovalResponse { - decision: CommandExecutionApprovalDecision::Decline, - })?, - ) - .await?; + assert_eq!(status, CommandExecutionStatus::InProgress); } else { - assert_eq!(status, CommandExecutionStatus::Declined); + assert_eq!(status, CommandExecutionStatus::Completed); } } diff --git a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs index 99c5951a23..f3f0aa2eb2 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs @@ -9,6 +9,7 @@ use anyhow::Result; use app_test_support::MockResponsesConfig; use app_test_support::TestAppServer; +use app_test_support::create_escalated_shell_command_sse_response; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; @@ -191,7 +192,7 @@ async fn turn_start_shell_zsh_fork_exec_approval_decline_v2() -> Result<()> { eprintln!("using zsh path for zsh-fork test: {}", zsh_path.display()); let responses = vec![ - create_shell_command_sse_response( + create_escalated_shell_command_sse_response( vec![ "python3".to_string(), "-c".to_string(), @@ -207,7 +208,7 @@ async fn turn_start_shell_zsh_fork_exec_approval_decline_v2() -> Result<()> { create_config_toml( &codex_home, &server.uri(), - "untrusted", + "on-request", &BTreeMap::from([ (Feature::ShellZshFork, true), (Feature::UnifiedExec, false), @@ -322,7 +323,7 @@ async fn turn_start_shell_zsh_fork_exec_approval_cancel_v2() -> Result<()> { }; eprintln!("using zsh path for zsh-fork test: {}", zsh_path.display()); - let responses = vec![create_shell_command_sse_response( + let responses = vec![create_escalated_shell_command_sse_response( vec![ "python3".to_string(), "-c".to_string(), @@ -336,7 +337,7 @@ async fn turn_start_shell_zsh_fork_exec_approval_cancel_v2() -> Result<()> { create_config_toml( &codex_home, &server.uri(), - "untrusted", + "on-request", &BTreeMap::from([ (Feature::ShellZshFork, true), (Feature::UnifiedExec, false), @@ -491,7 +492,7 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() create_config_toml( &codex_home, &server.uri(), - "untrusted", + "on-request", &BTreeMap::from([ (Feature::ShellZshFork, true), (Feature::UnifiedExec, false), diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index d7b7074489..0c9024841b 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -3214,22 +3214,16 @@ allowed_approvals_reviewers = ["user"] #[test] fn deserialize_allowed_approval_policies() -> Result<()> { let toml_str = r#" - allowed_approval_policies = ["untrusted", "on-request"] + allowed_approval_policies = ["on-request", "never"] "#; let config: ConfigRequirementsToml = from_str(toml_str)?; let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; assert_eq!( requirements.approval_policy.value(), - AskForApproval::UnlessTrusted, + AskForApproval::OnRequest, "currently, there is no way to specify the default value for approval policy in the toml, so it picks the first allowed value" ); - assert!( - requirements - .approval_policy - .can_set(&AskForApproval::UnlessTrusted) - .is_ok() - ); assert!( requirements .approval_policy @@ -3237,11 +3231,13 @@ allowed_approvals_reviewers = ["user"] .is_ok() ); assert_eq!( - requirements.approval_policy.can_set(&AskForApproval::Never), + requirements + .approval_policy + .can_set(&AskForApproval::UnlessTrusted), Err(ConstraintError::InvalidValue { field_name: "approval_policy", - candidate: "Never".into(), - allowed: "[UnlessTrusted, OnRequest]".into(), + candidate: "UnlessTrusted".into(), + allowed: "[OnRequest, Never]".into(), requirement_source: RequirementSource::Unknown, }) ); diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 08931f5474..ceff237fe8 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -170,6 +170,7 @@ pub struct ConfigToml { pub model_auto_compact_token_limit_scope: Option, /// Default approval policy for executing commands. + #[schemars(with = "Option")] pub approval_policy: Option, /// Configures who approval requests are routed to for review once they have diff --git a/codex-rs/config/src/profile_toml.rs b/codex-rs/config/src/profile_toml.rs index 7d13c02a41..076e19c364 100644 --- a/codex-rs/config/src/profile_toml.rs +++ b/codex-rs/config/src/profile_toml.rs @@ -29,6 +29,7 @@ pub struct ConfigProfile { /// The key in the `model_providers` map identifying the /// [`ModelProviderInfo`] to use. pub model_provider: Option, + #[schemars(with = "Option")] pub approval_policy: Option, pub approvals_reviewer: Option, pub sandbox_mode: Option, diff --git a/codex-rs/config/src/schema.rs b/codex-rs/config/src/schema.rs index 3ef954b47b..bcebcbb759 100644 --- a/codex-rs/config/src/schema.rs +++ b/codex-rs/config/src/schema.rs @@ -2,6 +2,8 @@ use crate::config_toml::ConfigToml; use crate::types::RawMcpServerConfig; use codex_features::FEATURES; use codex_features::legacy_feature_keys; +use codex_protocol::protocol::GranularApprovalConfig; +use schemars::JsonSchema; use schemars::r#gen::SchemaGenerator; use schemars::r#gen::SchemaSettings; use schemars::schema::InstanceType; @@ -14,6 +16,28 @@ use serde_json::Map; use serde_json::Value; use std::path::Path; +/// Determines the conditions under which the user is consulted to approve +/// running the command proposed by Codex. +#[allow(dead_code)] +#[derive(JsonSchema)] +#[schemars(rename = "AskForApproval")] +#[serde(rename_all = "kebab-case")] +pub(crate) enum ConfigAskForApproval { + /// The model decides when to ask the user for approval. + OnRequest, + + /// Fine-grained controls for individual approval flows. + /// + /// When a field is `true`, commands in that category are allowed. When it + /// is `false`, those requests are automatically rejected instead of shown + /// to the user. + Granular(GranularApprovalConfig), + + /// Never ask the user to approve commands. Failures are immediately returned + /// to the model, and never escalated to the user for approval. + Never, +} + /// Schema for the `[features]` map with known + legacy keys only. pub fn features_schema(schema_gen: &mut SchemaGenerator) -> Schema { let mut object = SchemaObject { diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index c806f53bcd..273f94e811 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -255,13 +255,6 @@ "AskForApproval": { "description": "Determines the conditions under which the user is consulted to approve running the command proposed by Codex.", "oneOf": [ - { - "description": "Under this policy, only \"known safe\" commands—as determined by `is_safe_command()`—that **only read files** are auto‑approved. Everything else will ask the user to approve.", - "enum": [ - "untrusted" - ], - "type": "string" - }, { "description": "The model decides when to ask the user for approval.", "enum": [ diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 48d2ea64b0..493b9ee48d 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -9319,7 +9319,7 @@ async fn model_catalog_json_rejects_empty_catalog() -> std::io::Result<()> { fn create_test_fixture() -> std::io::Result { let toml = r#" model = "o3" -approval_policy = "untrusted" +approval_policy = "on-request" [analytics] enabled = true @@ -10405,7 +10405,7 @@ async fn test_untrusted_project_gets_unless_trusted_approval_policy() -> anyhow: ) .await?; - // Verify that untrusted projects get UnlessTrusted approval policy + // Verify that untrusted projects get the internal always-prompt policy. assert_eq!( config.permissions.approval_policy.value(), AskForApproval::UnlessTrusted, @@ -10805,8 +10805,7 @@ trust_level = "untrusted" } #[tokio::test] -async fn explicit_approval_policy_falls_back_when_disallowed_by_requirements() -> std::io::Result<()> -{ +async fn explicit_untrusted_approval_policy_is_rejected() -> std::io::Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), @@ -10814,19 +10813,17 @@ async fn explicit_approval_policy_falls_back_when_disallowed_by_requirements() - "#, )?; - let config = ConfigBuilder::without_managed_config_for_tests() + let error = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_config_bundle( - CloudConfigBundleFixture::loader_with_enterprise_requirement( - r#"allowed_approval_policies = ["on-request"]"#, - ), - ) .build() - .await?; - assert_eq!( - config.permissions.approval_policy.value(), - AskForApproval::OnRequest + .await + .expect_err("untrusted approval policy should be rejected"); + assert!( + error + .to_string() + .contains("approval_policy = \"untrusted\" is no longer supported"), + "unexpected error: {error}" ); Ok(()) } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 7cfa392daf..fecce3bf4a 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -186,6 +186,11 @@ pub(crate) use resolved_permission_profile::PermissionProfileState; const DEFAULT_IGNORE_LARGE_UNTRACKED_DIRS: i64 = 200; const DEFAULT_IGNORE_LARGE_UNTRACKED_FILES: i64 = 10 * 1024 * 1024; +/// Signals that a public config selected the retired `untrusted` approval policy. +#[derive(Debug, thiserror::Error)] +#[error("approval_policy = \"untrusted\" is no longer supported; remove this setting")] +pub struct UnsupportedUntrustedApprovalPolicyError; + /// Compatibility-only config retained so legacy `ghost_snapshot` settings /// continue to load even though snapshots are no longer produced. #[derive(Debug, Clone, PartialEq, Eq)] @@ -3554,6 +3559,12 @@ impl Config { } configured_network_proxy_config.enabled = true; } + if cfg.approval_policy == Some(AskForApproval::UnlessTrusted) { + return Err(std::io::Error::new( + ErrorKind::InvalidData, + UnsupportedUntrustedApprovalPolicyError, + )); + } let approval_policy_was_explicit = approval_policy_override.is_some() || cfg.approval_policy.is_some(); let mut approval_policy = approval_policy_override diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 9e327353fe..f24b062469 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -26,7 +26,6 @@ use codex_protocol::permissions::FileSystemSandboxKind; use codex_protocol::protocol::AskForApproval; use codex_shell_command::is_dangerous_command::DangerousCommandMatch; use codex_shell_command::is_dangerous_command::dangerous_command_match; -use codex_shell_command::is_safe_command::is_known_safe_command; use thiserror::Error; use tokio::fs; use tokio::sync::Semaphore; @@ -150,7 +149,7 @@ pub(crate) static BANNED_PREFIX_SUGGESTIONS: &[&[&str]] = &[ /// /// The command tokens may be the original argv or a shell-specific lowering of /// a wrapper such as `bash -lc ...` or `powershell.exe -Command ...`. We only -/// need to distinguish the PowerShell case because its safelist and dangerous +/// need to distinguish the PowerShell case because its dangerous-command /// heuristics operate on PowerShell-flavored inner command words rather than /// the generic command classifier. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -740,17 +739,9 @@ pub(crate) fn render_decision_for_unmatched_command( permission_profile, windows_sandbox_level, sandbox_permissions, - command_origin, + command_origin: _, } = context; let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy(); - let is_known_safe = match command_origin { - ExecPolicyCommandOrigin::Generic => is_known_safe_command(command), - #[cfg(windows)] - ExecPolicyCommandOrigin::PowerShell => { - codex_shell_command::is_safe_command::is_safe_powershell_words(command) - } - }; - // When the Windows sandbox backend is disabled, managed filesystem // restrictions are only a policy shape; there is no platform sandbox to // enforce the boundary. Keep that legacy case conservative while still @@ -759,13 +750,6 @@ pub(crate) fn render_decision_for_unmatched_command( && windows_sandbox_level == WindowsSandboxLevel::Disabled && profile_has_managed_filesystem_restrictions(permission_profile); - if is_known_safe - && (approval_policy == AskForApproval::UnlessTrusted - || windows_managed_fs_restrictions_without_sandbox_backend) - { - return Decision::Allow; - } - // If the command is flagged as dangerous or we have no sandbox protection, // we should never allow it to run without approval. // @@ -789,8 +773,8 @@ pub(crate) fn render_decision_for_unmatched_command( Decision::Allow } AskForApproval::UnlessTrusted => { - // We already checked the unmatched-command safelist and it - // returned false, so we must prompt. + // Projects marked untrusted require approval for every command + // that is not explicitly allowed by an exec policy rule. Decision::Prompt } AskForApproval::OnRequest => { diff --git a/codex-rs/core/src/exec_policy_windows_tests.rs b/codex-rs/core/src/exec_policy_windows_tests.rs index 57820f1344..b07932eda1 100644 --- a/codex-rs/core/src/exec_policy_windows_tests.rs +++ b/codex-rs/core/src/exec_policy_windows_tests.rs @@ -67,11 +67,11 @@ fn commands_for_exec_policy_parses_powershell_shell_wrapper() { } #[test] -fn unmatched_safe_powershell_words_are_allowed() { +fn unmatched_powershell_read_requires_approval_without_sandbox() { let command = vec!["Get-Content".to_string(), "Cargo.toml".to_string()]; assert_eq!( - Decision::Allow, + Decision::Prompt, render_decision_for_unmatched_command( &command, UnmatchedCommandContext { diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 0138cda4ca..4233e5f94d 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -96,7 +96,6 @@ use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::handlers::ExecCommandHandler; use crate::tools::handlers::RequestPermissionsHandler; -use crate::tools::handlers::ShellCommandHandler; use crate::tools::registry::ToolExecutor; use crate::tools::router::ToolCallSource; use crate::turn_diff_tracker::TurnDiffTracker; @@ -11301,111 +11300,6 @@ async fn sample_rollout( ) } -#[tokio::test] -async fn rejects_escalated_permissions_when_policy_not_on_request() { - use crate::exec_policy::ExecApprovalRequest; - use crate::sandboxing::SandboxPermissions; - use crate::tools::sandboxing::ExecApprovalRequirement; - use crate::turn_diff_tracker::TurnDiffTracker; - use codex_protocol::protocol::AskForApproval; - use codex_tools::ShellCommandBackendConfig; - - let (session, mut turn_context_raw) = make_session_and_context().await; - // Ensure policy is NOT OnRequest so the early rejection path triggers - Arc::make_mut(&mut turn_context_raw.config) - .permissions - .approval_policy - .set(AskForApproval::Never) - .expect("test setup should allow updating approval policy"); - let session = Arc::new(session); - let mut turn_context = Arc::new(turn_context_raw); - let step_context = StepContext::for_test(Arc::clone(&turn_context)); - - let command_script = "echo hi"; - let timeout_ms = 1000; - let sandbox_permissions = SandboxPermissions::RequireEscalated; - - let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); - - let tool_name = "shell_command"; - let call_id = "test-call".to_string(); - - let handler = ShellCommandHandler::from(ShellCommandBackendConfig::Classic); - #[allow(deprecated)] - let workdir = Some(turn_context.cwd.to_string_lossy().to_string()); - let resp = handler - .handle(ToolInvocation { - session: Arc::clone(&session), - turn: Arc::clone(&turn_context), - step_context, - cancellation_token: CancellationToken::new(), - tracker: Arc::clone(&turn_diff_tracker), - call_id, - tool_name: codex_tools::ToolName::plain(tool_name), - source: crate::tools::context::ToolCallSource::Direct, - payload: ToolPayload::Function { - arguments: serde_json::json!({ - "command": command_script, - "workdir": workdir, - "timeout_ms": timeout_ms, - "sandbox_permissions": sandbox_permissions, - "justification": Some("test"), - }) - .to_string(), - }, - }) - .await; - - let Err(FunctionCallError::RespondToModel(output)) = resp else { - panic!("expected error result"); - }; - - let expected = format!( - "approval policy is {policy:?}; reject command — you should not ask for escalated permissions if the approval policy is {policy:?}", - policy = turn_context.approval_policy() - ); - - pretty_assertions::assert_eq!(output, expected); - pretty_assertions::assert_eq!( - session - .granted_turn_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID) - .await, - None - ); - - // The rejection should not poison the non-escalated path for the same - // command. Force DangerFullAccess so this check stays focused on approval - // policy rather than platform-specific sandbox behavior. - let turn_context_mut = Arc::get_mut(&mut turn_context).expect("unique thread settings Arc"); - Arc::make_mut(&mut turn_context_mut.config) - .permissions - .set_permission_profile(PermissionProfile::Disabled) - .expect("test setup should allow updating permission profile"); - - let command = session.user_shell().derive_exec_args( - command_script, - turn_context.config.permissions.allow_login_shell, - ); - let exec_approval_requirement = session - .services - .exec_policy - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, - approval_policy: turn_context.approval_policy(), - permission_profile: turn_context.permission_profile(), - environment_policy: None, - windows_sandbox_level: turn_context.windows_sandbox_level, - sandbox_permissions: SandboxPermissions::UseDefault, - prefix_rule: None, - allow_prefix_rules: turn_context.allow_prefix_rules(), - }) - .await; - assert!(matches!( - exec_approval_requirement, - ExecApprovalRequirement::Skip { .. } - )); -} - #[cfg(unix)] #[tokio::test] async fn shell_tool_cancellation_waits_for_runtime_cleanup() -> anyhow::Result<()> { diff --git a/codex-rs/core/src/tools/handlers/shell_tests.rs b/codex-rs/core/src/tools/handlers/shell_tests.rs index 7b7b238b23..4cf246a8b9 100644 --- a/codex-rs/core/src/tools/handlers/shell_tests.rs +++ b/codex-rs/core/src/tools/handlers/shell_tests.rs @@ -32,56 +32,10 @@ use crate::turn_diff_tracker::TurnDiffTracker; use codex_protocol::protocol::EnvironmentConfig; use codex_protocol::protocol::EnvironmentConfigState; use codex_protocol::protocol::TurnEnvironmentSelection; -use codex_shell_command::is_safe_command::is_known_safe_command; -use codex_shell_command::powershell::try_find_powershell_executable_blocking; -use codex_shell_command::powershell::try_find_pwsh_executable_blocking; use codex_utils_path_uri::PathUri; use serde_json::json; use tokio::sync::Mutex; -/// The logic for is_known_safe_command() has heuristics for known shells, -/// so we must ensure the commands generated by [ShellCommandHandler] can be -/// recognized as safe if the `command` is safe. -#[test] -fn commands_generated_by_shell_command_handler_can_be_matched_by_is_known_safe_command() { - let bash_shell = Shell { - shell_type: ShellType::Bash, - shell_path: PathBuf::from("/bin/bash"), - }; - assert_safe(&bash_shell, "ls -la"); - - let zsh_shell = Shell { - shell_type: ShellType::Zsh, - shell_path: PathBuf::from("/bin/zsh"), - }; - assert_safe(&zsh_shell, "ls -la"); - - if let Some(path) = try_find_powershell_executable_blocking() { - let powershell = Shell { - shell_type: ShellType::PowerShell, - shell_path: path.to_path_buf(), - }; - assert_safe(&powershell, "ls -Name"); - } - - if let Some(path) = try_find_pwsh_executable_blocking() { - let pwsh = Shell { - shell_type: ShellType::PowerShell, - shell_path: path.to_path_buf(), - }; - assert_safe(&pwsh, "ls -Name"); - } -} - -fn assert_safe(shell: &Shell, command: &str) { - assert!(is_known_safe_command(&shell.derive_exec_args( - command, /* use_login_shell */ /*use_login_shell*/ true - ))); - assert!(is_known_safe_command(&shell.derive_exec_args( - command, /* use_login_shell */ /*use_login_shell*/ false - ))); -} - #[tokio::test] async fn shell_command_handler_to_exec_params_uses_selected_environment() { let (session, mut turn_context) = make_session_and_context().await; diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index df5f1b4980..206020d53c 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -385,7 +385,7 @@ struct CoreShellActionProvider { #[allow(clippy::large_enum_variant)] enum DecisionSource { PrefixRule, - /// Often, this is `is_safe_command()`. + /// Derived from the unmatched-command sandbox fallback. UnmatchedCommandFallback, } diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 868fed1f5c..ec2b62375c 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -569,24 +569,24 @@ impl Expectation { assert_eq!( result.exit_code, Some(0), - "expected successful trusted command exit: {}", + "expected successful command exit: {}", result.stdout ); assert!( result.stdout.contains(stdout_contains), - "trusted command stdout missing {stdout_contains:?}: {}", + "command stdout missing {stdout_contains:?}: {}", result.stdout ); } Expectation::CommandSuccessNoExitCode { stdout_contains } => { assert!( result.exit_code.is_none() || result.exit_code == Some(0), - "expected no exit code for trusted command: {}", + "expected no exit code for command: {}", result.stdout ); assert!( result.stdout.contains(stdout_contains), - "trusted command stdout missing {stdout_contains:?}: {}", + "command stdout missing {stdout_contains:?}: {}", result.stdout ); } @@ -980,7 +980,7 @@ fn scenarios() -> Vec { }, }, ScenarioSpec { - name: "trusted_command_unless_trusted_runs_without_prompt", + name: "simple_command_unless_trusted_requires_approval", approval_policy: UnlessTrusted, sandbox_policy: SandboxPolicy::DangerFullAccess, action: ActionKind::RunCommand { @@ -989,13 +989,16 @@ fn scenarios() -> Vec { sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.2"), - outcome: Outcome::Auto, - expectation: Expectation::CommandSuccess { - stdout_contains: "trusted-unless", + outcome: Outcome::ExecApproval { + decision: ReviewDecision::denied("blocked in untrusted project"), + expected_reason: None, + }, + expectation: Expectation::CommandFailure { + output_contains: "blocked in untrusted project", }, }, ScenarioSpec { - name: "trusted_command_unless_trusted_runs_without_prompt_gpt_5_1_no_exit", + name: "simple_command_unless_trusted_requires_approval_gpt_5_1_no_exit", approval_policy: UnlessTrusted, sandbox_policy: SandboxPolicy::DangerFullAccess, action: ActionKind::RunCommand { @@ -1004,9 +1007,12 @@ fn scenarios() -> Vec { sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.4"), - outcome: Outcome::Auto, - expectation: Expectation::CommandSuccessNoExitCode { - stdout_contains: "trusted-unless", + outcome: Outcome::ExecApproval { + decision: ReviewDecision::denied("blocked in untrusted project"), + expected_reason: None, + }, + expectation: Expectation::CommandFailure { + output_contains: "blocked in untrusted project", }, }, ScenarioSpec { @@ -1046,7 +1052,7 @@ fn scenarios() -> Vec { }, }, ScenarioSpec { - name: "known_safe_escalation_on_request_requires_approval", + name: "simple_command_escalation_on_request_requires_approval", approval_policy: OnRequest, sandbox_policy: workspace_write(false), action: ActionKind::RunCommand { @@ -1068,7 +1074,7 @@ fn scenarios() -> Vec { }, }, ScenarioSpec { - name: "known_safe_escalation_granular_sandbox_disabled_rejects", + name: "simple_command_escalation_granular_sandbox_disabled_rejects", approval_policy: Granular(GranularApprovalConfig { sandbox_approval: false, rules: true, @@ -1287,7 +1293,7 @@ fn scenarios() -> Vec { }, }, ScenarioSpec { - name: "trusted_command_on_request_read_only_runs_without_prompt", + name: "simple_command_on_request_read_only_runs_without_prompt", approval_policy: OnRequest, sandbox_policy: SandboxPolicy::new_read_only_policy(), action: ActionKind::RunCommand { @@ -1302,7 +1308,7 @@ fn scenarios() -> Vec { }, }, ScenarioSpec { - name: "trusted_command_on_request_read_only_runs_without_prompt_gpt_5_1_no_exit", + name: "simple_command_on_request_read_only_runs_without_prompt_gpt_5_1_no_exit", approval_policy: OnRequest, sandbox_policy: SandboxPolicy::new_read_only_policy(), action: ActionKind::RunCommand { @@ -1606,7 +1612,7 @@ fn scenarios() -> Vec { }, }, ScenarioSpec { - name: "trusted_command_never_runs_without_prompt", + name: "simple_command_never_runs_without_prompt", approval_policy: Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), action: ActionKind::RunCommand { @@ -1732,7 +1738,7 @@ fn scenarios() -> Vec { }, }, ScenarioSpec { - name: "unified exec on request no approval for safe command", + name: "unified exec on request no approval for simple command", approval_policy: OnRequest, sandbox_policy: SandboxPolicy::DangerFullAccess, action: ActionKind::RunUnifiedExecCommand { @@ -1788,7 +1794,7 @@ fn scenarios() -> Vec { }, }, ScenarioSpec { - name: "safe command with heredoc and redirect still requires approval", + name: "heredoc with redirect still requires approval", approval_policy: AskForApproval::OnRequest, sandbox_policy: workspace_write(false), action: ActionKind::RunUnifiedExecCommand { @@ -1975,7 +1981,7 @@ fn scenarios() -> Vec { }, }, ScenarioSpec { - name: "quoted_shell_metacharacters_unless_trusted_run_without_prompt", + name: "quoted_shell_metacharacters_unless_trusted_require_approval", approval_policy: UnlessTrusted, sandbox_policy: workspace_write(false), action: ActionKind::RunCommand { @@ -1984,9 +1990,12 @@ fn scenarios() -> Vec { sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.2"), - outcome: Outcome::Auto, - expectation: Expectation::CommandSuccess { - stdout_contains: "-g*.py -{delete,print}", + outcome: Outcome::ExecApproval { + decision: ReviewDecision::denied("blocked in untrusted project"), + expected_reason: None, + }, + expectation: Expectation::CommandFailure { + output_contains: "blocked in untrusted project", }, }, ] diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index 531c8f8038..b9c8aa8505 100644 --- a/codex-rs/mcp-server/src/codex_tool_config.rs +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -36,7 +36,7 @@ pub struct CodexToolCallParam { pub cwd: Option, /// Approval policy for shell commands generated by the model: - /// `untrusted`, `on-request`, `never`. + /// `on-request`, `never`. #[serde(default, skip_serializing_if = "Option::is_none")] pub approval_policy: Option, @@ -67,7 +67,6 @@ pub struct CodexToolCallParam { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "kebab-case")] pub enum CodexToolCallApprovalPolicy { - Untrusted, OnRequest, Never, } @@ -75,7 +74,6 @@ pub enum CodexToolCallApprovalPolicy { impl From for AskForApproval { fn from(value: CodexToolCallApprovalPolicy) -> Self { match value { - CodexToolCallApprovalPolicy::Untrusted => AskForApproval::UnlessTrusted, CodexToolCallApprovalPolicy::OnRequest => AskForApproval::OnRequest, CodexToolCallApprovalPolicy::Never => AskForApproval::Never, } @@ -299,9 +297,8 @@ mod tests { "additionalProperties": false, "properties": { "approval-policy": { - "description": "Approval policy for shell commands generated by the model: `untrusted`, `on-request`, `never`.", + "description": "Approval policy for shell commands generated by the model: `on-request`, `never`.", "enum": [ - "untrusted", "on-request", "never" ], diff --git a/codex-rs/mcp-server/tests/common/responses.rs b/codex-rs/mcp-server/tests/common/responses.rs index 48a575a4c6..2b4d78ce18 100644 --- a/codex-rs/mcp-server/tests/common/responses.rs +++ b/codex-rs/mcp-server/tests/common/responses.rs @@ -14,6 +14,8 @@ pub fn create_shell_command_sse_response( "command": command_str, "workdir": workdir.map(|w| w.to_string_lossy()), "timeout_ms": timeout_ms, + "sandbox_permissions": "require_escalated", + "justification": "Test approval request.", }))?; let response_id = format!("resp-{call_id}"); Ok(responses::sse(vec![ diff --git a/codex-rs/mcp-server/tests/suite/codex_tool.rs b/codex-rs/mcp-server/tests/suite/codex_tool.rs index 0b430f3eb5..dcc6bf8b0c 100644 --- a/codex-rs/mcp-server/tests/suite/codex_tool.rs +++ b/codex-rs/mcp-server/tests/suite/codex_tool.rs @@ -221,7 +221,7 @@ fn create_expected_elicitation_request_params( } /// Test that patch approval triggers an elicitation request to the MCP and that -/// sending the approval applies the patch, as expected. +/// sending a denial leaves the patch unapplied, as expected. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_patch_approval_triggers_elicitation() { if env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { @@ -244,11 +244,14 @@ async fn patch_approval_triggers_elicitation() -> anyhow::Result<()> { } let cwd = TempDir::new()?; - let test_file = cwd.path().join("destination_file.txt"); - std::fs::write(&test_file, "original content\n")?; + let test_file = Path::new("/").join( + cwd.path() + .file_name() + .ok_or_else(|| anyhow::anyhow!("temporary directory must have a file name"))?, + ); let patch_content = format!( - "*** Begin Patch\n*** Update File: {}\n-original content\n+modified content\n*** End Patch", + "*** Begin Patch\n*** Add File: {}\n+new content\n*** End Patch", test_file.as_path().to_string_lossy() ); @@ -258,7 +261,7 @@ async fn patch_approval_triggers_elicitation() -> anyhow::Result<()> { dir: _dir, } = create_mcp_process(vec![ create_apply_patch_sse_response(&patch_content, "call1234")?, - create_final_assistant_message_sse_response("Patch has been applied successfully!")?, + create_final_assistant_message_sse_response("Patch was not applied.")?, ]) .await?; @@ -267,11 +270,12 @@ async fn patch_approval_triggers_elicitation() -> anyhow::Result<()> { .send_codex_tool_call(CodexToolCallParam { cwd: Some(cwd.path().to_string_lossy().to_string()), prompt: "please modify the test file".to_string(), - // This test exercises patch approval elicitation, not local sandbox setup. - config: Some(HashMap::from([( - "sandbox_mode".to_string(), - json!("danger-full-access"), - )])), + // Use the user reviewer so this test exercises MCP elicitation even when auto-review + // is enabled by the surrounding environment. + config: Some(HashMap::from([ + ("approvals_reviewer".to_string(), json!("user")), + ("features.guardian_approval".to_string(), json!(false)), + ])), ..Default::default() }) .await?; @@ -296,9 +300,8 @@ async fn patch_approval_triggers_elicitation() -> anyhow::Result<()> { let mut expected_changes = HashMap::new(); expected_changes.insert( test_file.as_path().to_path_buf(), - FileChange::Update { - unified_diff: "@@ -1 +1 @@\n-original content\n+modified content\n".to_string(), - move_path: None, + FileChange::Add { + content: "new content\n".to_string(), }, ); @@ -314,12 +317,12 @@ async fn patch_approval_triggers_elicitation() -> anyhow::Result<()> { )?) ); - // Accept the patch approval request by responding to the elicitation + // Deny the patch approval request so this test does not require the Linux sandbox helper. mcp_process .send_response( elicitation_request_id, serde_json::to_value(PatchApprovalResponse { - decision: ReviewDecision::Approved, + decision: ReviewDecision::denied("rejected by test"), })?, ) .await?; @@ -337,21 +340,20 @@ async fn patch_approval_triggers_elicitation() -> anyhow::Result<()> { result: json!({ "content": [ { - "text": "Patch has been applied successfully!", + "text": "Patch was not applied.", "type": "text" } ], "structuredContent": { "threadId": params.thread_id, - "content": "Patch has been applied successfully!" + "content": "Patch was not applied." } }), }, codex_response ); - let file_contents = std::fs::read_to_string(test_file.as_path())?; - assert_eq!(file_contents, "modified content\n"); + assert!(!test_file.exists()); Ok(()) } @@ -644,8 +646,8 @@ async fn create_mcp_process(responses: Vec) -> anyhow::Result } /// Create a Codex config that uses the mock server as the model provider. -/// It also uses `approval_policy = "untrusted"` so that we exercise the -/// elicitation code path for shell commands. +/// The shell command explicitly requests escalation so that we exercise the +/// elicitation code path. fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { let config_toml = codex_home.join("config.toml"); std::fs::write( @@ -653,7 +655,7 @@ fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<() format!( r#" model = "mock-model" -approval_policy = "untrusted" +approval_policy = "on-request" sandbox_policy = "workspace-write" model_provider = "mock_provider" diff --git a/codex-rs/memories/read/src/usage.rs b/codex-rs/memories/read/src/usage.rs index 8691531483..cb9503efca 100644 --- a/codex-rs/memories/read/src/usage.rs +++ b/codex-rs/memories/read/src/usage.rs @@ -1,6 +1,4 @@ use codex_protocol::parse_command::ParsedCommand; -use codex_shell_command::bash::parse_shell_script_into_commands; -use codex_shell_command::is_safe_command::is_known_safe_command; use codex_shell_command::parse_command::parse_shell_script; pub use crate::metrics::MEMORIES_USAGE_METRIC; @@ -27,17 +25,15 @@ impl MemoriesUsageKind { } pub fn memories_usage_kinds_from_command(command: &str) -> Vec { - let Some(commands) = parse_shell_script_into_commands(command) else { - return Vec::new(); - }; - if !commands + let commands = parse_shell_script(command); + if commands .iter() - .all(|command| is_known_safe_command(command)) + .any(|command| matches!(command, ParsedCommand::Unknown { .. })) { return Vec::new(); } - parse_shell_script(command) + commands .into_iter() .filter_map(|command| match command { ParsedCommand::Read { path, .. } => get_memory_kind(path.display().to_string()), diff --git a/codex-rs/prompts/templates/permissions/approval_policy/unless_trusted.md b/codex-rs/prompts/templates/permissions/approval_policy/unless_trusted.md index 039f702656..e997ef2839 100644 --- a/codex-rs/prompts/templates/permissions/approval_policy/unless_trusted.md +++ b/codex-rs/prompts/templates/permissions/approval_policy/unless_trusted.md @@ -1 +1 @@ - Approvals are your mechanism to get user consent to run shell commands without the sandbox. `approval_policy` is `unless-trusted`: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands. + `approval_policy` is `unless-trusted`: The harness will require user approval before running commands unless an explicit exec policy rule allows them. diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 2cf3e39cd9..b2230b0aea 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -914,9 +914,8 @@ impl Op { #[serde(rename_all = "kebab-case")] #[strum(serialize_all = "kebab-case")] pub enum AskForApproval { - /// Under this policy, only "known safe" commands—as determined by - /// `is_safe_command()`—that **only read files** are auto‑approved. - /// Everything else will ask the user to approve. + /// Internal policy for projects marked untrusted. Commands require + /// approval unless an explicit exec policy rule allows them. #[serde(rename = "untrusted")] #[strum(serialize = "untrusted")] UnlessTrusted, diff --git a/codex-rs/shell-command/src/bash.rs b/codex-rs/shell-command/src/bash.rs index 5157a61f73..ddd5807bfc 100644 --- a/codex-rs/shell-command/src/bash.rs +++ b/codex-rs/shell-command/src/bash.rs @@ -315,7 +315,6 @@ fn parse_raw_string(node: Node, src: &str) -> Option { #[cfg(test)] mod tests { use super::*; - use crate::is_safe_command::is_known_safe_command; use pretty_assertions::assert_eq; fn parse_seq(src: &str) -> Option>> { @@ -444,7 +443,6 @@ mod tests { for flag in ["-c", "-lc"] { let command = [shell, flag, script].map(str::to_owned); assert_eq!(parse_shell_lc_plain_commands(&command), None, "{command:?}"); - assert!(!is_known_safe_command(&command), "{command:?}"); } } } @@ -485,7 +483,7 @@ mod tests { assert_eq!(parse_seq(script), None, "{script:?}"); for shell in ["bash", "zsh"] { let command = [shell, "-lc", script].map(str::to_owned); - assert!(!is_known_safe_command(&command), "{command:?}"); + assert_eq!(parse_shell_lc_plain_commands(&command), None, "{command:?}"); } } } diff --git a/codex-rs/shell-command/src/command_safety/is_safe_command.rs b/codex-rs/shell-command/src/command_safety/is_safe_command.rs deleted file mode 100644 index 1f24a6d21d..0000000000 --- a/codex-rs/shell-command/src/command_safety/is_safe_command.rs +++ /dev/null @@ -1,536 +0,0 @@ -use crate::bash::parse_shell_lc_plain_commands; -use crate::command_safety::is_dangerous_command::executable_name_lookup_key; -#[cfg(windows)] -use crate::command_safety::windows_safe_commands::is_safe_command_windows; -#[cfg(windows)] -use crate::command_safety::windows_safe_commands::is_safe_powershell_words as is_safe_powershell_words_windows; - -pub fn is_known_safe_command(command: &[String]) -> bool { - let command: Vec = command - .iter() - .map(|s| { - if s == "zsh" { - "bash".to_string() - } else { - s.clone() - } - }) - .collect(); - - #[cfg(windows)] - { - if is_safe_command_windows(&command) { - return true; - } - } - - if is_safe_to_call_with_exec(&command) { - return true; - } - - // Support `bash -lc "..."` where the script consists solely of one or - // more "plain" commands (only bare words / quoted strings) combined with - // a conservative allow‑list of shell operators that themselves do not - // introduce side effects ( "&&", "||", ";", and "|" ). If every - // individual command in the script is itself a known‑safe command, then - // the composite expression is considered safe. - if let Some(all_commands) = parse_shell_lc_plain_commands(&command) - && !all_commands.is_empty() - && all_commands - .iter() - .all(|cmd| is_safe_to_call_with_exec(cmd)) - { - return true; - } - false -} - -/// Returns whether already-tokenized PowerShell words are read-only enough to -/// be auto-approved by the Windows safelist. -pub fn is_safe_powershell_words(command: &[String]) -> bool { - #[cfg(windows)] - { - is_safe_powershell_words_windows(command) - } - - #[cfg(not(windows))] - { - let _ = command; - false - } -} - -fn is_safe_to_call_with_exec(command: &[String]) -> bool { - let Some(cmd0) = command.first().map(String::as_str) else { - return false; - }; - - match executable_name_lookup_key(cmd0).as_deref() { - Some(cmd) if cfg!(target_os = "linux") && matches!(cmd, "numfmt" | "tac") => true, - - #[rustfmt::skip] - Some( - "cat" | - "cd" | - "cut" | - "echo" | - "expr" | - "false" | - "grep" | - "head" | - "id" | - "ls" | - "nl" | - "paste" | - "pwd" | - "rev" | - "seq" | - "stat" | - "tail" | - "tr" | - "true" | - "uname" | - "uniq" | - "wc" | - "which" | - "whoami") => { - true - }, - - Some("base64") => { - const UNSAFE_BASE64_OPTIONS: &[&str] = &["-o", "--output"]; - - !command.iter().skip(1).any(|arg| { - UNSAFE_BASE64_OPTIONS.contains(&arg.as_str()) - || arg.starts_with("--output=") - || (arg.starts_with("-o") && arg != "-o") - }) - } - - Some("find") => { - // Certain options to `find` can delete files, write to files, or - // execute arbitrary commands, so we cannot auto-approve the - // invocation of `find` in such cases. - #[rustfmt::skip] - const UNSAFE_FIND_OPTIONS: &[&str] = &[ - // Options that can execute arbitrary commands. - "-exec", "-execdir", "-ok", "-okdir", - // Option that deletes matching files. - "-delete", - // Options that write pathnames to a file. - "-fls", "-fprint", "-fprint0", "-fprintf", - ]; - - !command - .iter() - .any(|arg| UNSAFE_FIND_OPTIONS.contains(&arg.as_str())) - } - - // Ripgrep - Some("rg") => { - const UNSAFE_RIPGREP_OPTIONS_WITH_ARGS: &[&str] = &[ - // Takes an arbitrary command that is executed for each match. - "--pre", - // Takes a command that can be used to obtain the local hostname. - "--hostname-bin", - ]; - const UNSAFE_RIPGREP_OPTIONS_WITHOUT_ARGS: &[&str] = &[ - // Calls out to other decompression tools, so do not auto-approve - // out of an abundance of caution. - "--search-zip", - "-z", - ]; - - !command.iter().any(|arg| { - UNSAFE_RIPGREP_OPTIONS_WITHOUT_ARGS.contains(&arg.as_str()) - || UNSAFE_RIPGREP_OPTIONS_WITH_ARGS - .iter() - .any(|&opt| arg == opt || arg.starts_with(&format!("{opt}="))) - }) - } - - // Repository configuration can make even read-only Git commands execute helpers. - Some("git") => false, - - // Special-case `sed -n {N|M,N}p` - Some("sed") - if { - command.len() <= 4 - && command.get(1).map(String::as_str) == Some("-n") - && is_valid_sed_n_arg(command.get(2).map(String::as_str)) - } => - { - true - } - - // ── anything else ───────────────────────────────────────────────── - _ => false, - } -} - -// (bash parsing helpers implemented in crate::bash) - -/* ---------------------------------------------------------- -Example ----------------------------------------------------------- */ - -/// Returns true if `arg` matches /^(\d+,)?\d+p$/ -fn is_valid_sed_n_arg(arg: Option<&str>) -> bool { - // unwrap or bail - let s = match arg { - Some(s) => s, - None => return false, - }; - - // must end with 'p', strip it - let core = match s.strip_suffix('p') { - Some(rest) => rest, - None => return false, - }; - - // split on ',' and ensure 1 or 2 numeric parts - let parts: Vec<&str> = core.split(',').collect(); - match parts.as_slice() { - // single number, e.g. "10" - [num] => !num.is_empty() && num.chars().all(|c| c.is_ascii_digit()), - - // two numbers, e.g. "1,5" - [a, b] => { - !a.is_empty() - && !b.is_empty() - && a.chars().all(|c| c.is_ascii_digit()) - && b.chars().all(|c| c.is_ascii_digit()) - } - - // anything else (more than one comma) is invalid - _ => false, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::string::ToString; - - fn vec_str(args: &[&str]) -> Vec { - args.iter().map(ToString::to_string).collect() - } - - #[test] - fn known_safe_examples() { - assert!(is_safe_to_call_with_exec(&vec_str(&["ls"]))); - assert!(is_safe_to_call_with_exec(&vec_str(&["base64"]))); - assert!(is_safe_to_call_with_exec(&vec_str(&[ - "sed", "-n", "1,5p", "file.txt" - ]))); - assert!(is_safe_to_call_with_exec(&vec_str(&[ - "nl", - "-nrz", - "Cargo.toml" - ]))); - - // Safe `find` command (no unsafe options). - assert!(is_safe_to_call_with_exec(&vec_str(&[ - "find", ".", "-name", "file.txt" - ]))); - - if cfg!(target_os = "linux") { - assert!(is_safe_to_call_with_exec(&vec_str(&["numfmt", "1000"]))); - assert!(is_safe_to_call_with_exec(&vec_str(&["tac", "Cargo.toml"]))); - } else { - assert!(!is_safe_to_call_with_exec(&vec_str(&["numfmt", "1000"]))); - assert!(!is_safe_to_call_with_exec(&vec_str(&["tac", "Cargo.toml"]))); - } - } - - #[test] - fn git_commands_are_not_known_safe() { - for args in [ - vec_str(&["git", "status", "--short"]), - vec_str(&["git", "log", "-p", "-1"]), - vec_str(&["git", "diff"]), - vec_str(&["git", "show", "HEAD"]), - vec_str(&["git", "branch"]), - vec_str(&["git", "branch", "--show-current"]), - vec_str(&["git", "--version"]), - vec_str(&["/usr/bin/git", "status"]), - vec_str(&["bash", "-lc", "git status"]), - vec_str(&["zsh", "-lc", "cd nested && git status"]), - vec_str(&["bash", "-lc", "git diff | head -20"]), - ] { - assert!( - !is_known_safe_command(&args), - "Git must not be trusted from its arguments alone: {args:?}", - ); - } - } - - #[test] - fn cargo_check_is_not_safe() { - assert!(!is_known_safe_command(&vec_str(&["cargo", "check"]))); - } - - #[test] - fn zsh_lc_safe_command_sequence() { - assert!(is_known_safe_command(&vec_str(&["zsh", "-lc", "ls"]))); - } - - #[test] - fn unknown_or_partial() { - assert!(!is_safe_to_call_with_exec(&vec_str(&["foo"]))); - assert!(!is_safe_to_call_with_exec(&vec_str(&["git", "fetch"]))); - assert!(!is_safe_to_call_with_exec(&vec_str(&[ - "sed", "-n", "xp", "file.txt" - ]))); - - // Unsafe `find` commands. - for args in [ - vec_str(&["find", ".", "-name", "file.txt", "-exec", "rm", "{}", ";"]), - vec_str(&[ - "find", ".", "-name", "*.py", "-execdir", "python3", "{}", ";", - ]), - vec_str(&["find", ".", "-name", "file.txt", "-ok", "rm", "{}", ";"]), - vec_str(&["find", ".", "-name", "*.py", "-okdir", "python3", "{}", ";"]), - vec_str(&["find", ".", "-delete", "-name", "file.txt"]), - vec_str(&["find", ".", "-fls", "/etc/passwd"]), - vec_str(&["find", ".", "-fprint", "/etc/passwd"]), - vec_str(&["find", ".", "-fprint0", "/etc/passwd"]), - vec_str(&["find", ".", "-fprintf", "/root/suid.txt", "%#m %u %p\n"]), - ] { - assert!( - !is_safe_to_call_with_exec(&args), - "expected {args:?} to be unsafe" - ); - } - } - - #[test] - fn base64_output_options_are_unsafe() { - for args in [ - vec_str(&["base64", "-o", "out.bin"]), - vec_str(&["base64", "--output", "out.bin"]), - vec_str(&["base64", "--output=out.bin"]), - vec_str(&["base64", "-ob64.txt"]), - ] { - assert!( - !is_safe_to_call_with_exec(&args), - "expected {args:?} to be considered unsafe due to output option" - ); - } - } - - #[test] - fn ripgrep_rules() { - // Safe ripgrep invocations – none of the unsafe flags are present. - assert!(is_safe_to_call_with_exec(&vec_str(&[ - "rg", - "Cargo.toml", - "-n" - ]))); - - // Unsafe flags that do not take an argument (present verbatim). - for args in [ - vec_str(&["rg", "--search-zip", "files"]), - vec_str(&["rg", "-z", "files"]), - ] { - assert!( - !is_safe_to_call_with_exec(&args), - "expected {args:?} to be considered unsafe due to zip-search flag", - ); - } - - // Unsafe flags that expect a value, provided in both split and = forms. - for args in [ - vec_str(&["rg", "--pre", "pwned", "files"]), - vec_str(&["rg", "--pre=pwned", "files"]), - vec_str(&["rg", "--hostname-bin", "pwned", "files"]), - vec_str(&["rg", "--hostname-bin=pwned", "files"]), - ] { - assert!( - !is_safe_to_call_with_exec(&args), - "expected {args:?} to be considered unsafe due to external-command flag", - ); - } - } - - #[test] - fn windows_powershell_full_path_is_safe() { - if !cfg!(windows) { - // Windows only because on Linux path splitting doesn't handle `/` separators properly - return; - } - - let Some(powershell) = crate::powershell::try_find_pwsh_executable_blocking() - .or_else(crate::powershell::try_find_powershell_executable_blocking) - else { - return; - }; - let powershell = powershell.as_path().to_str().unwrap(); - - assert!(is_known_safe_command(&vec_str(&[ - powershell, - "-Command", - "Get-Location", - ]))); - } - - #[test] - fn windows_git_full_path_is_not_safe() { - if !cfg!(windows) { - return; - } - - assert!(!is_known_safe_command(&vec_str(&[ - r"C:\Program Files\Git\cmd\git.exe", - "status", - ]))); - } - - #[test] - fn bash_lc_safe_examples() { - assert!(is_known_safe_command(&vec_str(&["bash", "-lc", "ls"]))); - assert!(is_known_safe_command(&vec_str(&["bash", "-lc", "ls -1"]))); - assert!(is_known_safe_command(&vec_str(&[ - "bash", - "-lc", - "grep -R \"Cargo.toml\" -n" - ]))); - assert!(is_known_safe_command(&vec_str(&[ - "bash", - "-lc", - "sed -n 1,5p file.txt" - ]))); - assert!(is_known_safe_command(&vec_str(&[ - "bash", - "-lc", - "sed -n '1,5p' file.txt" - ]))); - - assert!(is_known_safe_command(&vec_str(&[ - "bash", - "-lc", - "find . -name file.txt" - ]))); - } - - #[test] - fn bash_lc_safe_examples_with_operators() { - assert!(is_known_safe_command(&vec_str(&[ - "bash", - "-lc", - "grep -R \"Cargo.toml\" -n || true" - ]))); - assert!(is_known_safe_command(&vec_str(&[ - "bash", - "-lc", - "ls && pwd" - ]))); - assert!(is_known_safe_command(&vec_str(&[ - "bash", - "-lc", - "echo 'hi' ; ls" - ]))); - assert!(is_known_safe_command(&vec_str(&[ - "bash", - "-lc", - "ls | wc -l" - ]))); - } - - #[test] - fn bash_lc_unsafe_examples() { - assert!( - !is_known_safe_command(&vec_str(&["bash", "-lc", "git", "status"])), - "Four arg version is not known to be safe." - ); - assert!( - !is_known_safe_command(&vec_str(&["bash", "-lc", "'git status'"])), - "The extra quoting around 'git status' makes it a program named 'git status' and is therefore unsafe." - ); - - assert!( - !is_known_safe_command(&vec_str(&["bash", "-lc", "find . -name file.txt -delete"])), - "Unsafe find option should not be auto-approved." - ); - - // Disallowed because of unsafe command in sequence. - assert!( - !is_known_safe_command(&vec_str(&["bash", "-lc", "ls && rm -rf /"])), - "Sequence containing unsafe command must be rejected" - ); - - // Disallowed because of parentheses / subshell. - assert!( - !is_known_safe_command(&vec_str(&["bash", "-lc", "(ls)"])), - "Parentheses (subshell) are not provably safe with the current parser" - ); - assert!( - !is_known_safe_command(&vec_str(&["bash", "-lc", "ls || (pwd && echo hi)"])), - "Nested parentheses are not provably safe with the current parser" - ); - - // Disallowed redirection. - assert!( - !is_known_safe_command(&vec_str(&["bash", "-lc", "ls > out.txt"])), - "> redirection should be rejected" - ); - } - - #[test] - fn direct_powershell_words_use_windows_safelist() { - let command = vec_str(&["Get-Content", "Cargo.toml"]); - - if cfg!(windows) { - assert!(is_safe_powershell_words(&command)); - } else { - assert!(!is_safe_powershell_words(&command)); - } - } - - #[cfg(unix)] - #[test] - fn non_windows_safe_classification_does_not_spawn_repo_powershell_path() { - use std::fs; - use std::io::Write; - use std::os::unix::fs::PermissionsExt; - use std::time::SystemTime; - use std::time::UNIX_EPOCH; - - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock should be after unix epoch") - .as_nanos(); - let temp_dir = std::env::temp_dir().join(format!( - "codex-safe-command-pwsh-test-{}-{unique}", - std::process::id() - )); - fs::create_dir(&temp_dir).expect("create temp dir for fake pwsh"); - let fake_pwsh = temp_dir.join("pwsh"); - let marker = temp_dir.join("marker"); - let quoted_marker = marker.to_string_lossy().replace('\'', "'\\''"); - - let mut script = fs::File::create(&fake_pwsh).expect("create fake pwsh"); - writeln!( - script, - "#!/bin/sh\nprintf spawned > '{quoted_marker}'\nexit 0" - ) - .expect("write fake pwsh"); - let mut permissions = fs::metadata(&fake_pwsh) - .expect("stat fake pwsh") - .permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&fake_pwsh, permissions).expect("make fake pwsh executable"); - - assert!(!is_known_safe_command(&[ - fake_pwsh.to_string_lossy().into_owned(), - "-Command".to_string(), - "Get-ChildItem".to_string(), - ])); - assert!( - !marker.exists(), - "non-Windows safety classification must not spawn a PowerShell-looking path" - ); - - fs::remove_dir_all(temp_dir).expect("remove temp dir"); - } -} diff --git a/codex-rs/shell-command/src/command_safety/mod.rs b/codex-rs/shell-command/src/command_safety/mod.rs index 3225841e08..44d0103a9d 100644 --- a/codex-rs/shell-command/src/command_safety/mod.rs +++ b/codex-rs/shell-command/src/command_safety/mod.rs @@ -6,7 +6,4 @@ mod powershell_parser; mod powershell_tree_sitter; pub mod is_dangerous_command; -pub mod is_safe_command; -#[cfg(windows)] -pub(crate) mod windows_safe_commands; pub(crate) use powershell_tree_sitter::try_parse_powershell_commands; diff --git a/codex-rs/shell-command/src/command_safety/windows_safe_commands.rs b/codex-rs/shell-command/src/command_safety/windows_safe_commands.rs deleted file mode 100644 index 3395108d23..0000000000 --- a/codex-rs/shell-command/src/command_safety/windows_safe_commands.rs +++ /dev/null @@ -1,503 +0,0 @@ -use crate::command_safety::try_parse_powershell_commands; -use std::path::Path; - -/// On Windows, we conservatively allow only clearly read-only PowerShell invocations -/// that match a small safelist. Anything else (including direct CMD commands) is unsafe. -pub fn is_safe_command_windows(command: &[String]) -> bool { - if let Some(commands) = try_parse_powershell_command_sequence(command) { - commands - .iter() - .all(|cmd| is_safe_powershell_words(cmd.as_slice())) - } else { - // Only PowerShell invocations are allowed on Windows for now; anything else is unsafe. - false - } -} - -/// Returns each command sequence if the invocation starts with a PowerShell binary. -/// For example, the tokens from `pwsh Get-ChildItem | Measure-Object` become two sequences. -fn try_parse_powershell_command_sequence(command: &[String]) -> Option>> { - let (exe, rest) = command.split_first()?; - if is_powershell_executable(exe) { - parse_powershell_invocation(exe, rest) - } else { - None - } -} - -/// Parses a PowerShell invocation into discrete command vectors, rejecting unsafe patterns. -fn parse_powershell_invocation(executable: &str, args: &[String]) -> Option>> { - if args.is_empty() { - // Examples rejected here: "pwsh" and "powershell.exe" with no additional arguments. - return None; - } - - let mut idx = 0; - while idx < args.len() { - let arg = &args[idx]; - let lower = arg.to_ascii_lowercase(); - match lower.as_str() { - "-command" | "/command" | "-c" => { - let script = args.get(idx + 1)?; - if idx + 2 != args.len() { - // Reject if there is more than one token representing the actual command. - // Examples rejected here: "pwsh -Command foo bar" and "powershell -c ls extra". - return None; - } - return parse_powershell_script(executable, script); - } - _ if lower.starts_with("-command:") || lower.starts_with("/command:") => { - if idx + 1 != args.len() { - // Reject if there are more tokens after the command itself. - // Examples rejected here: "pwsh -Command:dir C:\\" and "powershell /Command:dir C:\\" with trailing args. - return None; - } - let script = arg.split_once(':')?.1; - return parse_powershell_script(executable, script); - } - - // Benign, no-arg flags we tolerate. - "-nologo" | "-noprofile" | "-noninteractive" | "-mta" | "-sta" => { - idx += 1; - continue; - } - - // Explicitly forbidden/opaque or unnecessary for read-only operations. - "-encodedcommand" | "-ec" | "-file" | "/file" | "-windowstyle" | "-executionpolicy" - | "-workingdirectory" => { - // Examples rejected here: "pwsh -EncodedCommand ..." and "powershell -File script.ps1". - return None; - } - - // Unknown switch → bail conservatively. - _ if lower.starts_with('-') => { - // Examples rejected here: "pwsh -UnknownFlag" and "powershell -foo bar". - return None; - } - - // If we hit non-flag tokens, treat the remainder as a command sequence. - // This happens if powershell is invoked without -Command, e.g. - // ["pwsh", "-NoLogo", "git", "-c", "core.pager=cat", "status"] - _ => { - let script = join_arguments_as_script(&args[idx..]); - return parse_powershell_script(executable, &script); - } - } - } - - // Examples rejected here: "pwsh" and "powershell.exe -NoLogo" without a script. - None -} - -/// Tokenizes an inline PowerShell script and delegates to the command splitter. -/// Examples of when this is called: pwsh.exe -Command '