diff --git a/codex-rs/core/tests/suite/shell_command.rs b/codex-rs/core/tests/suite/shell_command.rs index 8fe5f976cd..2e8bcfa1c4 100644 --- a/codex-rs/core/tests/suite/shell_command.rs +++ b/codex-rs/core/tests/suite/shell_command.rs @@ -2,6 +2,8 @@ use std::time::Duration; use anyhow::Result; use codex_protocol::models::PermissionProfile; +use codex_protocol::shell_environment::CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR; +use core_test_support::TestTargetOs; use core_test_support::assert_regex_match; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -11,9 +13,11 @@ use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::skip_if_host_windows; use core_test_support::skip_if_no_network; +use core_test_support::skip_if_wine_exec; use core_test_support::test_codex::TestCodexBuilder; use core_test_support::test_codex::TestCodexHarness; use core_test_support::test_codex::test_codex; +use core_test_support::test_target_os; use pretty_assertions::assert_eq; use serde_json::json; use test_case::test_case; @@ -126,6 +130,41 @@ async fn shell_command_works() -> anyhow::Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shell_command_does_not_expose_configured_noise_auth_token() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_wine_exec!(Ok(()), "shell_command is unavailable for Wine executors"); + + let builder = test_codex().with_model("gpt-5.4").with_config(|config| { + config.permissions.shell_environment_policy.r#set.insert( + CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR.to_string(), + "configured-noise-token".to_string(), + ); + config.permissions.shell_environment_policy.r#set.insert( + CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR.to_ascii_lowercase(), + "case-variant-noise-token".to_string(), + ); + }); + let harness = TestCodexHarness::with_auto_env_builder(builder).await?; + let command = match test_target_os() { + TestTargetOs::Linux | TestTargetOs::MacOs => { + "if [ -n \"${CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN:-}\" ] || [ -n \"${codex_exec_server_noise_auth_token:-}\" ]; then echo leaked; else echo unset; fi" + } + TestTargetOs::Windows => { + "if ($env:CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN) { Write-Output leaked } else { Write-Output unset }" + } + }; + let call_id = "shell-command-noise-auth-token"; + mount_shell_responses(&harness, call_id, command, /*login*/ None).await; + harness + .submit("check the remote execution auth token") + .await?; + + assert_shell_command_output(&harness.function_call_stdout(call_id).await, "unset")?; + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn shell_command_rejects_justification_without_sandbox_permissions() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index df631145c1..6f7105950f 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -9,6 +9,7 @@ use codex_http_client::HttpClientFactory; use codex_http_client::OutboundProxyPolicy; use codex_protocol::capabilities::CapabilityRootLocation; use codex_protocol::capabilities::SelectedCapabilityRoot; +use codex_protocol::shell_environment::CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR; use crate::CapabilityRootsDiscoverParams; use crate::CapabilityRootsDiscoverResponse; @@ -50,7 +51,6 @@ pub const CODEX_EXEC_SERVER_NOISE_REGISTRY_URL_ENV_VAR: &str = "CODEX_EXEC_SERVER_NOISE_REGISTRY_URL"; pub const CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID_ENV_VAR: &str = "CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID"; -pub const CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR: &str = "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN"; pub const CODEX_EXEC_SERVER_NOISE_CHATGPT_ACCOUNT_ID_ENV_VAR: &str = "CODEX_EXEC_SERVER_NOISE_CHATGPT_ACCOUNT_ID"; diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index bfaec24cad..6d5e266d1b 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -78,7 +78,7 @@ pub use codex_file_system::WalkEntryKind; pub use codex_file_system::WalkError; pub use codex_file_system::WalkOptions; pub use codex_file_system::WalkOutcome; -pub use environment::CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR; +pub use codex_protocol::shell_environment::CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR; pub use environment::CODEX_EXEC_SERVER_NOISE_CHATGPT_ACCOUNT_ID_ENV_VAR; pub use environment::CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID_ENV_VAR; pub use environment::CODEX_EXEC_SERVER_NOISE_REGISTRY_URL_ENV_VAR; diff --git a/codex-rs/hooks/src/engine/command_runner_tests.rs b/codex-rs/hooks/src/engine/command_runner_tests.rs index fc4ae89902..6cee21aede 100644 --- a/codex-rs/hooks/src/engine/command_runner_tests.rs +++ b/codex-rs/hooks/src/engine/command_runner_tests.rs @@ -12,6 +12,7 @@ use codex_protocol::protocol::HookOutputEntry; use codex_protocol::protocol::HookOutputEntryKind; use codex_protocol::protocol::HookRunStatus; use codex_protocol::protocol::HookSource; +use codex_protocol::shell_environment::CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use tempfile::TempDir; @@ -113,6 +114,48 @@ async fn fast_exiting_hook_preserves_stdout_when_stdin_is_not_consumed() { assert_eq!(result.error, None); } +#[tokio::test] +async fn command_hook_does_not_expose_configured_noise_auth_token() { + let temp = tempdir().expect("create temp dir"); + let source_path = AbsolutePathBuf::try_from(temp.path().join("hooks.json")) + .expect("absolute hook configuration path"); + let command = if cfg!(windows) { "set" } else { "env" }; + let env = HashMap::from([ + ( + CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR.to_ascii_lowercase(), + "configured-noise-token".to_string(), + ), + ("CODEX_HOOK_SAFE_ENV".to_string(), "visible".to_string()), + ]); + let handler = ConfiguredHandler { + event_name: HookEventName::SessionStart, + matcher: None, + timeout_sec: 10, + status_message: None, + additional_context_limit: Default::default(), + source_path, + source: HookSource::User, + display_order: 0, + kind: ConfiguredHandlerKind::Command { + command: command.to_string(), + r#async: false, + env: env.clone(), + }, + }; + let (runtime, _result_receiver) = runtime(); + + let result = run_command(&runtime, &handler, command, &env, "{}", temp.path()).await; + + assert_eq!(result.exit_code, Some(0), "stderr: {}", result.stderr); + assert!(result.stdout.contains("CODEX_HOOK_SAFE_ENV=visible")); + assert!(!result.stdout.lines().any(|line| { + line.split_once('=').is_some_and(|(name, _)| { + name.eq_ignore_ascii_case(CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR) + }) + })); + assert_eq!(result.error, None); +} + const ASYNC_HOOK_TEST_TIMEOUT: Duration = Duration::from_secs(30); fn runtime() -> (CommandHookRuntime, Receiver) { diff --git a/codex-rs/protocol/src/shell_environment.rs b/codex-rs/protocol/src/shell_environment.rs index ae3ccb2251..ebe06a97c8 100644 --- a/codex-rs/protocol/src/shell_environment.rs +++ b/codex-rs/protocol/src/shell_environment.rs @@ -5,12 +5,14 @@ use std::collections::HashMap; pub const CODEX_SESSION_ID_ENV_VAR: &str = "CODEX_SESSION_ID"; pub const CODEX_THREAD_ID_ENV_VAR: &str = "CODEX_THREAD_ID"; +pub const CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR: &str = "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN"; pub const OPENAI_FEDERATION_RULE_ID_ENV_VAR: &str = "OPENAI_FEDERATION_RULE_ID"; pub const OPENAI_IDENTITY_TOKEN_FILE_ENV_VAR: &str = "OPENAI_IDENTITY_TOKEN_FILE"; pub const OPENAI_WORKLOAD_IDENTITY_CONTEXT_ENV_VAR: &str = "OPENAI_WORKLOAD_IDENTITY_CONTEXT"; /// Environment variables that model-reachable child processes must not inherit. pub const NON_INHERITABLE_ENV_VARS: &[&str] = &[ + CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR, OPENAI_FEDERATION_RULE_ID_ENV_VAR, OPENAI_IDENTITY_TOKEN_FILE_ENV_VAR, OPENAI_WORKLOAD_IDENTITY_CONTEXT_ENV_VAR, diff --git a/codex-rs/protocol/src/shell_environment_tests.rs b/codex-rs/protocol/src/shell_environment_tests.rs index d6c4009909..ad8a60ef94 100644 --- a/codex-rs/protocol/src/shell_environment_tests.rs +++ b/codex-rs/protocol/src/shell_environment_tests.rs @@ -21,6 +21,10 @@ fn non_inheritable_environment_is_removed_after_policy_overrides() { "OPENAI_WORKLOAD_IDENTITY_CONTEXT".to_string(), r#"{"instance_id":"box-one"}"#.to_string(), ), + ( + "codex_exec_server_noise_auth_token".to_string(), + "inherited-noise-token".to_string(), + ), ]; let policy = ShellEnvironmentPolicy { inherit: ShellEnvironmentPolicyInherit::All, @@ -31,6 +35,10 @@ fn non_inheritable_environment_is_removed_after_policy_overrides() { "OpenAI_Identity_Token_File".to_string(), "/run/identity-token".to_string(), ), + ( + "Codex_Exec_Server_Noise_Auth_Token".to_string(), + "configured-noise-token".to_string(), + ), ]), ..Default::default() }; @@ -52,6 +60,10 @@ fn command_scrubber_removes_names_from_real_child_environment() { "OpenAI_Workload_Identity_Context", r#"{"instance_id":"box-one"}"#, ) + .env( + "Codex_Exec_Server_Noise_Auth_Token", + "inherited-noise-token", + ) .output() .expect("run inherited-environment test process"); assert!( @@ -66,6 +78,10 @@ fn command_scrubber_removes_names_from_real_child_environment() { let mut command = environment_command(); command .env("openai_identity_token_file", "/run/identity-token") + .env( + CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR, + "configured-noise-token", + ) .env("SAFE", "value"); scrub_non_inheritable_env_vars(&mut command); let output = command.output().expect("read child environment");