diff --git a/codex-rs/config/src/loader/tests.rs b/codex-rs/config/src/loader/tests.rs index 412167fd68..9be08e94f1 100644 --- a/codex-rs/config/src/loader/tests.rs +++ b/codex-rs/config/src/loader/tests.rs @@ -971,14 +971,16 @@ async fn local_layers_keep_raw_paths_order_and_legacy_requirements() { std::fs::write( &system_file, "model_instructions_file='./system.md'\n\ - [shell_environment_policy.set]\nGH_HOST='github.stale.example'\n", + [shell_environment_policy.set]\nGH_HOST='github.stale.example'\n\ + GH_ENTERPRISE_TOKEN='ghp_stale'\n", ) .expect("write stale system GitHub host"); std::fs::write( &user_file, format!( "{}\n[features.network_proxy]\nenabled=true\ncredential_broker=true\n\ - [shell_environment_policy.set]\ngh_host='github.trusted.example'\n", + [shell_environment_policy.set]\ngh_host='github.trusted.example'\n\ + gh_enterprise_token='ghp_trusted'\n", user_config("trusted") ), ) @@ -1007,6 +1009,8 @@ async fn local_layers_keep_raw_paths_order_and_legacy_requirements() { Some("github.trusted.example") ); assert!(!binding.contains_key("GH_HOST")); + assert!(!binding.contains_key("gh_enterprise_token")); + assert!(!binding.contains_key("GH_ENTERPRISE_TOKEN")); } std::fs::write(&user_file, user_config("untrusted")).expect("write user config"); diff --git a/codex-rs/config/src/merge.rs b/codex-rs/config/src/merge.rs index 14a5c5a5af..177436cf60 100644 --- a/codex-rs/config/src/merge.rs +++ b/codex-rs/config/src/merge.rs @@ -1,6 +1,6 @@ use crate::key_aliases::normalize_key_aliases; use crate::key_aliases::normalized_with_key_aliases; -use codex_network_proxy::credential_broker_provider_context_env_keys; +use codex_network_proxy::is_credential_broker_provider_env_key; use codex_network_proxy::normalize_host; use toml::Value as TomlValue; @@ -109,10 +109,10 @@ fn merge_toml_values_at_path(base: &mut TomlValue, overlay: &TomlValue, path: &m if cfg!(windows) && matches!(path.as_slice(), [policy, field] if policy == "shell_environment_policy" && field == "set") { - for key in overlay_table.keys().filter(|key| { - credential_broker_provider_context_env_keys() - .any(|binding_key| key.eq_ignore_ascii_case(binding_key)) - }) { + for key in overlay_table + .keys() + .filter(|key| is_credential_broker_provider_env_key(key)) + { base_table.retain(|candidate, _| { candidate == key || !candidate.eq_ignore_ascii_case(key) }); diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index fc1b99ffcc..95b78ebace 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -119,6 +119,8 @@ pub enum ExecCapturePolicy { FullBuffer, /// Internal helpers that need complete output but must honor timeout and cancellation. FullBufferWithExpiration, + /// Full-buffer helpers that honor expiration and suppress unredacted sandbox diagnostics. + SensitiveFullBuffer, } fn select_process_exec_tool_sandbox_type( @@ -268,7 +270,7 @@ impl ExecCapturePolicy { fn retained_bytes_cap(self) -> Option { match self { Self::ShellTool => Some(EXEC_OUTPUT_MAX_BYTES), - Self::FullBuffer | Self::FullBufferWithExpiration => None, + Self::FullBuffer | Self::FullBufferWithExpiration | Self::SensitiveFullBuffer => None, } } @@ -278,7 +280,7 @@ impl ExecCapturePolicy { fn uses_expiration(self) -> bool { match self { - Self::ShellTool | Self::FullBufferWithExpiration => true, + Self::ShellTool | Self::FullBufferWithExpiration | Self::SensitiveFullBuffer => true, Self::FullBuffer => false, } } @@ -470,7 +472,7 @@ pub(crate) async fn execute_exec_request( ) .await; let duration = start.elapsed(); - finalize_exec_result(raw_output_result, sandbox, duration) + finalize_exec_result(raw_output_result, sandbox, duration, capture_policy) } #[allow(clippy::too_many_arguments)] @@ -748,6 +750,7 @@ fn finalize_exec_result( raw_output_result: std::result::Result, sandbox_type: SandboxType, duration: Duration, + capture_policy: ExecCapturePolicy, ) -> Result { match raw_output_result { Ok(raw_output) => { @@ -789,7 +792,9 @@ fn finalize_exec_result( } if is_likely_sandbox_denied(sandbox_type, &exec_output) { - record_filesystem_sandbox_violation(sandbox_type, &exec_output); + if capture_policy != ExecCapturePolicy::SensitiveFullBuffer { + record_filesystem_sandbox_violation(sandbox_type, &exec_output); + } return Err(CodexErr::Sandbox(SandboxErr::Denied { output: Box::new(exec_output), network_policy_decision: None, @@ -1070,7 +1075,10 @@ async fn consume_output( let mut stdout_handle = stdout_handle; let mut stderr_handle = stderr_handle; - let (stdout, stderr) = if capture_policy == ExecCapturePolicy::FullBufferWithExpiration { + let (stdout, stderr) = if matches!( + capture_policy, + ExecCapturePolicy::FullBufferWithExpiration | ExecCapturePolicy::SensitiveFullBuffer + ) { let mut stdout_done = false; let mut stderr_done = false; let drain = async { diff --git a/codex-rs/core/src/exec_tests.rs b/codex-rs/core/src/exec_tests.rs index b343126dd5..fde92af514 100644 --- a/codex-rs/core/src/exec_tests.rs +++ b/codex-rs/core/src/exec_tests.rs @@ -341,16 +341,20 @@ enum CaptureDrainFailure { } #[cfg(unix)] -#[test_case("stdout", CaptureDrainFailure::Cancellation; "cancel_stdout")] -#[test_case("stderr", CaptureDrainFailure::Cancellation; "cancel_stderr")] -#[test_case("stdout", CaptureDrainFailure::Timeout; "timeout_stdout")] -#[test_case("stderr", CaptureDrainFailure::Timeout; "timeout_stderr")] -#[test_case("stdout", CaptureDrainFailure::DrainTimeout; "incomplete_stdout")] -#[test_case("stderr", CaptureDrainFailure::DrainTimeout; "incomplete_stderr")] +#[test_case("stdout", CaptureDrainFailure::Cancellation, ExecCapturePolicy::FullBufferWithExpiration; "cancel_stdout")] +#[test_case("stderr", CaptureDrainFailure::Cancellation, ExecCapturePolicy::FullBufferWithExpiration; "cancel_stderr")] +#[test_case("stdout", CaptureDrainFailure::Timeout, ExecCapturePolicy::FullBufferWithExpiration; "timeout_stdout")] +#[test_case("stderr", CaptureDrainFailure::Timeout, ExecCapturePolicy::FullBufferWithExpiration; "timeout_stderr")] +#[test_case("stdout", CaptureDrainFailure::DrainTimeout, ExecCapturePolicy::FullBufferWithExpiration; "incomplete_stdout")] +#[test_case("stderr", CaptureDrainFailure::DrainTimeout, ExecCapturePolicy::FullBufferWithExpiration; "incomplete_stderr")] +#[test_case("stdout", CaptureDrainFailure::Cancellation, ExecCapturePolicy::SensitiveFullBuffer; "sensitive_cancel")] +#[test_case("stderr", CaptureDrainFailure::Timeout, ExecCapturePolicy::SensitiveFullBuffer; "sensitive_timeout")] +#[test_case("stdout", CaptureDrainFailure::DrainTimeout, ExecCapturePolicy::SensitiveFullBuffer; "sensitive_drain_timeout")] #[tokio::test] async fn full_buffer_expiration_cleans_up_after_leader_exit( pipe: &str, failure: CaptureDrainFailure, + capture_policy: ExecCapturePolicy, ) -> anyhow::Result<()> { let dir = tempfile::tempdir()?; let redirect = match pipe { @@ -379,7 +383,7 @@ async fn full_buffer_expiration_cleans_up_after_leader_exit( let capture = consume_output( child, expiration, - ExecCapturePolicy::FullBufferWithExpiration, + capture_policy, /*stdout_stream*/ None, ); let after_leader_exit = async { diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index ad48f88ed7..c8b689dbf2 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -20,7 +20,10 @@ use codex_network_proxy::NetworkProxy; use codex_network_proxy::brokered_credential_binding_env_keys; use codex_network_proxy::brokered_credential_dummy_env_keys; use codex_network_proxy::brokered_credential_env_keys; +use codex_network_proxy::brokered_credential_marker_env_keys; +use codex_network_proxy::brokered_credential_value_env_keys; use codex_network_proxy::credential_broker_provider_context_env_keys; +use codex_network_proxy::credential_broker_provider_sources_allowed; use codex_network_proxy::is_credential_broker_provider_env_key; use codex_otel::SessionTelemetry; use codex_protocol::ThreadId; @@ -80,8 +83,13 @@ struct SnapshotCredentials { network_proxy: NetworkProxy, broker_config_revision: u64, shell_environment_policy: ShellEnvironmentPolicy, + protected_startup_env: Option, credential_env: HashMap, + unset_credential_keys: Vec, + // Retain discovered key identity even when policy replaces or removes its value. + startup_credential_keys: Vec, context_env: HashMap, + fail_open_aliases: HashMap, context_credential_keys: HashMap>, binding_context_credential_keys: HashMap>, } @@ -92,6 +100,12 @@ struct SnapshotCredentialBroker { allow_login_shell: bool, } +struct FailOpenAlias { + value: String, + dummy_value: Option, + authorized: bool, +} + const SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(10); const SNAPSHOT_RETENTION: Duration = Duration::from_secs(60 * 60 * 24 * 3); // 3 days retention. const SNAPSHOT_DIR: &str = "shell_snapshots"; @@ -332,6 +346,18 @@ impl ShellSnapshotFile { self.path.clone() } + pub(crate) fn protected_startup_env(&self) -> Option<&str> { + self.credentials + .as_ref() + .and_then(|credentials| credentials.protected_startup_env.as_deref()) + } + + pub(crate) fn startup_credential_keys(&self) -> &[String] { + self.credentials.as_ref().map_or(&[], |credentials| { + credentials.startup_credential_keys.as_slice() + }) + } + pub(crate) fn restore_credentials( &self, env: &mut HashMap, @@ -341,7 +367,7 @@ impl ShellSnapshotFile { return; }; - let allowed_snapshot_env = create_env_from_vars( + let mut allowed_snapshot_env = create_env_from_vars( credentials .credential_env .iter() @@ -351,6 +377,12 @@ impl ShellSnapshotFile { /*thread_id*/ None, ); let explicit_env_overrides = &shell_environment_policy.r#set; + for key in &credentials.unset_credential_keys { + if !contains_env_key(explicit_env_overrides, key) { + remove_env_value(env, key); + remove_env_value(&mut allowed_snapshot_env, key); + } + } let mut snapshot_real_credentials = credentials.credential_env.clone(); snapshot_real_credentials.insert( CREDENTIAL_BROKER_ACTIVE_ENV_KEY.to_string(), @@ -361,12 +393,13 @@ impl ShellSnapshotFile { .restore_brokered_credentials(&mut snapshot_real_credentials, &mut []); let mut restored_credential_keys = Vec::new(); for (key, value) in &credentials.credential_env { - if explicit_env_overrides.contains_key(key) || !allowed_snapshot_env.contains_key(key) { + if contains_env_key(explicit_env_overrides, key) + || !contains_env_key(&allowed_snapshot_env, key) + { continue; } - let current = env.entry(key.clone()).or_default(); - if snapshot_real_credentials.get(key) != Some(current) { - current.clone_from(value); + if snapshot_real_credentials.get(key) != env_value(env, key) { + insert_env_value(env, key, value); restored_credential_keys.push(key); } } @@ -374,11 +407,11 @@ impl ShellSnapshotFile { let Some(credential_keys) = credentials.context_credential_keys.get(key) else { continue; }; - if explicit_env_overrides.contains_key(key) - || !allowed_snapshot_env.contains_key(key) + if contains_env_key(explicit_env_overrides, key) + || !contains_env_key(&allowed_snapshot_env, key) || !credential_keys .iter() - .any(|credential_key| allowed_snapshot_env.contains_key(credential_key)) + .any(|credential_key| contains_env_key(&allowed_snapshot_env, credential_key)) { continue; } @@ -390,8 +423,8 @@ impl ShellSnapshotFile { .iter() .any(|credential_key| restored_credential_keys.contains(&credential_key)) }); - if restores_binding_context || !env.contains_key(key) { - env.insert(key.clone(), value.clone()); + if restores_binding_context || !contains_env_key(env, key) { + insert_env_value(env, key, value); } } @@ -408,6 +441,57 @@ impl ShellSnapshotFile { env.remove(CREDENTIAL_BROKER_ACTIVE_ENV_KEY); } } + + pub(crate) fn restore_fail_open_aliases(&self, env: &mut HashMap) { + let Some(credentials) = self.credentials.as_ref() else { + return; + }; + if env_value(env, CREDENTIAL_BROKER_ACTIVE_ENV_KEY).is_some_and(|active| active == "1") { + for (key, alias) in &credentials.fail_open_aliases { + let has_snapshot_dummy = alias + .dummy_value + .as_ref() + .is_some_and(|dummy| env_value(env, key) == Some(dummy)); + if has_snapshot_dummy { + if !alias.authorized { + remove_env_value(env, key); + } + continue; + } + if env_value(env, key).is_some_and(|value| { + let mut virtualized = value.clone(); + !credentials + .network_proxy + .virtualize_brokered_text(&mut virtualized, env) + }) { + remove_env_value(env, key); + } + } + return; + } + + let policy = &credentials.shell_environment_policy; + for (key, alias) in &credentials.fail_open_aliases { + if contains_env_key(&policy.r#set, key) + || credentials.unset_credential_keys.contains(key) + { + continue; + } + let has_snapshot_dummy = alias + .dummy_value + .as_ref() + .is_some_and(|dummy| env_value(env, key) == Some(dummy)); + if has_snapshot_dummy { + if alias.authorized { + insert_env_value(env, key, &alias.value); + } else { + remove_env_value(env, key); + } + } else if alias.authorized && !contains_env_key(env, key) { + insert_env_value(env, key, &alias.value); + } + } + } } impl Drop for ShellSnapshotFile { @@ -499,6 +583,11 @@ async fn capture_snapshot( .filter_map(|entry| entry.split_once('=')) .map(|(key, value)| (key.to_string(), value.to_string())) .collect::>(); + // The inherited broker marker survives startup even when its credential variables do not. + let mut unset_credential_keys = brokered_credential_marker_env_keys(&original_env) + .into_iter() + .filter(|key| !contains_env_key(&original_env, key)) + .collect::>(); let policy = &credential_broker.shell_environment_policy; let inherited_env = create_env_from_vars(std::env::vars(), policy, /*thread_id*/ None); let mut restored_env = original_env.clone(); @@ -512,11 +601,11 @@ async fn capture_snapshot( credential_broker_provider_context_env_keys(), ); for (key, value) in &policy.r#set { - if !discovery_env.contains_key(key) + if !contains_env_key(&discovery_env, key) || credential_broker_provider_context_env_keys() .any(|context_key| context_key.eq_ignore_ascii_case(key)) { - discovery_env.insert(key.clone(), value.clone()); + insert_env_value(&mut discovery_env, key, value); } } credential_broker @@ -537,15 +626,128 @@ async fn capture_snapshot( ); credential_broker.network_proxy.apply_to_env(&mut env); let allowed_brokered_keys = brokered_credential_dummy_env_keys(&env); + let unbrokered_credentials: HashMap = env + .iter() + .filter(|(key, _)| { + is_credential_broker_provider_env_key(key) + && !credential_broker_provider_context_env_keys() + .any(|context| context.eq_ignore_ascii_case(key)) + && !allowed_brokered_keys.contains(key) + }) + .map(|(key, value)| (key.clone(), value.clone())) + .collect(); + let protected_startup_env = capture.startup_environment.as_ref().and_then(|startup| { + let startup_env = String::from_utf8_lossy(startup.environment) + .split('\0') + .filter_map(|entry| entry.split_once('=')) + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect::>(); + original_env + .iter() + .any(|(key, value)| { + env_value(&startup_env, key) != Some(value) && { + let mut virtualized = value.clone(); + credential_broker + .network_proxy + .virtualize_brokered_text(&mut virtualized, &env); + virtualized != *value + } + }) + .then(|| startup.path.to_string()) + }); + let mut brokered_alias_keys = Vec::new(); + let fail_open_aliases: HashMap = env + .iter() + .filter(|(key, _)| !is_credential_broker_provider_env_key(key)) + .filter_map(|(key, value)| { + let mut real_value = env_value(&restored_env, key) + .or_else(|| env_value(&policy.r#set, key))? + .clone(); + let restored_dummy = credential_broker + .network_proxy + .restore_brokered_text(&mut real_value); + let mut virtualized = real_value.clone(); + let virtualized_allowed = credential_broker + .network_proxy + .virtualize_brokered_text(&mut virtualized, &env); + let has_brokered_source = brokered_keys.iter().any(|source| { + env_value(&restored_env, source) + .or_else(|| env_value(&policy.r#set, source)) + .is_some_and(|source_value| { + real_value == *source_value + || source_value.len() >= 16 && real_value.contains(source_value) + }) + }); + let brokered_alias = !has_brokered_source + && real_value != *value + && (restored_dummy || virtualized_allowed && virtualized == *value); + if !brokered_alias && virtualized_allowed { + return None; + } + if brokered_alias { + brokered_alias_keys.push(key.clone()); + } + + let authorized = credential_broker_provider_sources_allowed( + &real_value, + if brokered_alias { value } else { &virtualized }, + &restored_env, + |source| { + let allowed_sources = create_env_from_vars( + std::iter::once((source.to_string(), "1".to_string())), + policy, + /*thread_id*/ None, + ); + env_value(&allowed_sources, source) + .is_some_and(|source_value| !source_value.is_empty()) + && env_value(&policy.r#set, source).is_none_or(|source_value| { + env_value(&restored_env, source) == Some(source_value) + }) + }, + ); + Some(( + key.clone(), + FailOpenAlias { + value: real_value, + dummy_value: brokered_alias.then(|| value.clone()), + authorized, + }, + )) + }) + .collect(); + // Known unbound credentials also need live source references for their aliases. + let mut snapshot_credential_keys = brokered_keys.clone(); + snapshot_credential_keys.extend( + restored_env + .iter() + .filter(|&(key, value)| { + is_credential_broker_provider_env_key(key) + && !credential_broker_provider_context_env_keys() + .any(|context| context.eq_ignore_ascii_case(key)) + && !value.is_empty() + }) + .map(|(key, _)| key.clone()), + ); + snapshot_credential_keys.sort_unstable(); + snapshot_credential_keys.dedup(); + let mut allowed_snapshot_credential_keys = allowed_brokered_keys.clone(); + allowed_snapshot_credential_keys.extend( + unbrokered_credentials + .iter() + .filter(|(_, value)| !value.is_empty()) + .map(|(key, _)| key.clone()), + ); let mut snapshot_env = env.clone(); for key in credential_broker_provider_context_env_keys() { - if original_env.get(key) != env.get(key) { - snapshot_env.remove(key); + if env_value(&original_env, key) != env_value(&env, key) { + remove_env_value(&mut snapshot_env, key); } } - let PreparedSnapshot { - script: snapshot, .. - } = prepare_snapshot_credentials( + let Some(PreparedSnapshot { + script: snapshot, + aliases: mut credential_env, + rejected_alias_keys, + }) = prepare_snapshot_credentials( &capture, SnapshotCredentialEnvironment { original: &original_env, @@ -561,9 +763,9 @@ async fn capture_snapshot( ) .contains_key(key) }, - brokered_keys: &brokered_keys, - brokered_alias_keys: &[], - allowed_brokered_keys: &allowed_brokered_keys, + brokered_keys: &snapshot_credential_keys, + brokered_alias_keys: &brokered_alias_keys, + allowed_brokered_keys: &allowed_snapshot_credential_keys, }, |value| { credential_broker @@ -571,12 +773,51 @@ async fn capture_snapshot( .virtualize_brokered_text(value, &env) }, ) - .ok_or_else(|| anyhow!("shell snapshot contains a credential outside supported exports"))?; + else { + bail!("shell snapshot contains a brokered credential outside exported environment"); + }; + let mut startup_credential_keys = brokered_credential_value_env_keys(&discovery_env); + startup_credential_keys.extend(unbrokered_credentials.keys().cloned()); + unset_credential_keys.extend(rejected_alias_keys.into_iter().filter(|key| { + startup_credential_keys.contains(key) + || env_value(&original_env, key).is_some_and(|value| { + snapshot_credential_keys.iter().any(|source| { + !brokered_keys.contains(source) + && env_value(&restored_env, source).is_some_and(|real| { + value == real || real.len() >= 16 && value.contains(real) + }) + }) + }) + })); - let credential_env = allowed_brokered_keys - .iter() - .filter_map(|key| env.get(key).map(|value| (key.clone(), value.clone()))) - .collect(); + credential_env.extend( + allowed_brokered_keys + .iter() + .filter_map(|key| env_value(&env, key).map(|value| (key.clone(), value.clone()))), + ); + credential_env.extend(unbrokered_credentials); + for key in &brokered_alias_keys { + if fail_open_aliases + .get(key) + .is_some_and(|alias| alias.authorized) + && let Some(value) = env_value(&env, key) + { + credential_env.insert(key.clone(), value.clone()); + } else { + unset_credential_keys.push(key.clone()); + } + } + unset_credential_keys.extend( + startup_credential_keys + .iter() + .filter(|key| !contains_env_key(&env, key)) + .cloned(), + ); + unset_credential_keys.sort_unstable(); + unset_credential_keys.dedup(); + startup_credential_keys.extend(unset_credential_keys.iter().cloned()); + startup_credential_keys.sort_unstable(); + startup_credential_keys.dedup(); let mut context_credential_keys = HashMap::>::new(); let mut binding_context_credential_keys = HashMap::>::new(); for key in &allowed_brokered_keys { @@ -600,7 +841,7 @@ async fn capture_snapshot( } let context_env = context_credential_keys .keys() - .filter_map(|key| env.get(key).map(|value| (key.clone(), value.clone()))) + .filter_map(|key| env_value(&env, key).map(|value| (key.clone(), value.clone()))) .collect(); Ok(( snapshot, @@ -608,8 +849,12 @@ async fn capture_snapshot( network_proxy: credential_broker.network_proxy.clone(), broker_config_revision, shell_environment_policy: policy.clone(), + protected_startup_env, credential_env, + unset_credential_keys, + startup_credential_keys, context_env, + fail_open_aliases, context_credential_keys, binding_context_credential_keys, }), @@ -622,12 +867,41 @@ fn replace_provider_context_with_inherited( context_keys: impl Iterator, ) { for key in context_keys { - if let Some(value) = inherited_env.get(key) { - env.insert(key.to_string(), value.clone()); + if let Some(value) = env_value(inherited_env, key) { + insert_env_value(env, key, value); } } } +fn contains_env_key(env: &HashMap, key: &str) -> bool { + env_value(env, key).is_some() +} + +fn env_value<'a>(env: &'a HashMap, key: &str) -> Option<&'a String> { + env.get(key).or_else(|| { + cfg!(windows) + .then(|| { + env.iter().find_map(|(candidate, value)| { + candidate.eq_ignore_ascii_case(key).then_some(value) + }) + }) + .flatten() + }) +} + +fn insert_env_value(env: &mut HashMap, key: &str, value: &str) { + #[cfg(windows)] + env.retain(|candidate, _| !candidate.eq_ignore_ascii_case(key)); + env.insert(key.to_string(), value.to_string()); +} + +fn remove_env_value(env: &mut HashMap, key: &str) { + #[cfg(windows)] + env.retain(|candidate, _| !candidate.eq_ignore_ascii_case(key)); + #[cfg(not(windows))] + env.remove(key); +} + #[derive(Clone, Copy)] enum SnapshotShellMode<'a> { Login, @@ -676,30 +950,10 @@ async fn run_script_with_timeout( let mut prepared_env = None; if let Some(credential_broker) = credential_broker { let policy = &credential_broker.shell_environment_policy; - let mut inherited_policy = policy.clone(); - inherited_policy.r#set.clear(); - let mut env = - create_env_from_vars(std::env::vars(), &inherited_policy, /*thread_id*/ None); - env.extend( - policy - .r#set - .iter() - .filter(|(key, _)| { - (is_credential_broker_provider_env_key(key) - || matches!( - (shell.shell_type, key.as_str()), - (ShellType::Zsh, "ZDOTDIR") | (ShellType::Bash, "BASH_ENV") - )) - && (policy.include_only.is_empty() - || policy - .include_only - .iter() - .any(|pattern| pattern.matches(key))) - }) - .map(|(key, value)| (key.clone(), value.clone())), - ); + let mut env = create_env_from_vars(std::env::vars(), policy, /*thread_id*/ None); if suppress_startup_files { env.remove("BASH_ENV"); + env.remove("ENV"); } credential_broker.network_proxy.apply_to_env(&mut env); prepared_env = Some(env); diff --git a/codex-rs/core/src/shell_snapshot_sandbox.rs b/codex-rs/core/src/shell_snapshot_sandbox.rs index 3d3b924594..758aa3dbd5 100644 --- a/codex-rs/core/src/shell_snapshot_sandbox.rs +++ b/codex-rs/core/src/shell_snapshot_sandbox.rs @@ -197,7 +197,7 @@ impl ShellSnapshotSandbox { request, ExecOptions { expiration, - capture_policy: ExecCapturePolicy::FullBufferWithExpiration, + capture_policy: ExecCapturePolicy::SensitiveFullBuffer, }, workspace_roots, ) diff --git a/codex-rs/core/src/shell_snapshot_tests.rs b/codex-rs/core/src/shell_snapshot_tests.rs index 6ca44a49e9..9222989e8f 100644 --- a/codex-rs/core/src/shell_snapshot_tests.rs +++ b/codex-rs/core/src/shell_snapshot_tests.rs @@ -2,6 +2,12 @@ use super::*; #[cfg(unix)] use crate::config::NetworkProxySpec; #[cfg(unix)] +use crate::tools::runtimes::RuntimePathPrepends; +#[cfg(unix)] +use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot; +#[cfg(unix)] +use crate::tools::runtimes::prepare_brokered_shell_snapshot_env; +#[cfg(unix)] use codex_network_proxy::NetworkProxyConfig; #[cfg(unix)] use codex_protocol::config_types::EnvironmentVariablePattern; @@ -88,6 +94,27 @@ fn assert_posix_snapshot_sections(snapshot: &str) { assert!(snapshot.contains("setopts ")); } +#[cfg(windows)] +#[test] +fn inherited_provider_context_matches_windows_environment_casing() { + let mut env = HashMap::new(); + let inherited = HashMap::from([("gh_host".to_string(), "github.example.com".to_string())]); + + replace_provider_context_with_inherited(&mut env, &inherited, std::iter::once("GH_HOST")); + + assert_eq!( + env, + HashMap::from([("GH_HOST".to_string(), "github.example.com".to_string())]) + ); + + insert_env_value(&mut env, "gh_host", "github.alternate.example"); + assert_eq!( + env_value(&env, "GH_HOST").map(String::as_str), + Some("github.alternate.example") + ); + assert_eq!(env.len(), 1); +} + async fn get_snapshot(shell_type: ShellType) -> Result { let dir = tempdir()?; let path = dir.path().join("snapshot.sh"); @@ -532,7 +559,8 @@ async fn snapshot_discovers_and_redacts_shell_initialized_credentials() -> Resul let startup = dir.path().join("startup.sh"); std::fs::write( &startup, - "export GH_TOKEN='ghp_shell_only_secret'\n\ + "unset GITHUB_ENTERPRISE_TOKEN UNSET_AUTH_HEADER\n\ + export GH_TOKEN='ghp_shell_only_secret'\n\ export AUTH_HEADER=\"Bearer $GH_TOKEN\"\n\ declare -rx GITHUB_TOKEN='ghp_readonly_secret'\n\ declare -rx HOMEBREW_GITHUB_API_TOKEN=\"$GITHUB_TOKEN\"\n\ @@ -544,7 +572,9 @@ async fn snapshot_discovers_and_redacts_shell_initialized_credentials() -> Resul export OPENAI_BASE_URL='https://api.snapshot.example/v1'\n\ export IDENTITY_SEEN=\"${OPENAI_IDENTITY_TOKEN_FILE-missing}\"\n\ export EXCLUDED_PARENT_HOME=\"${HOME-missing}\"\n\ - export STARTUP_PATH_OVERRIDE_SEEN=\"${PATH%%:*}\"\n", + export STARTUP_PATH_OVERRIDE_SEEN=\"${PATH%%:*}\"\n\ + export STARTUP_CORP_REGION_SEEN=\"${CORP_REGION-missing}\"\n\ + export STARTUP_NPM_TOKEN_SEEN=\"${NPM_TOKEN-missing}\"\n", )?; let mut network_config = NetworkProxyConfig::default(); @@ -576,14 +606,21 @@ async fn snapshot_discovers_and_redacts_shell_initialized_credentials() -> Resul r#set: HashMap::from([ trusted_startup.clone(), ("GH_HOST".to_string(), "github.example.com".to_string()), + ( + "GITHUB_ENTERPRISE_TOKEN".to_string(), + "ghp_removed_enterprise_secret".to_string(), + ), + ( + "UNSET_AUTH_HEADER".to_string(), + "Bearer ghp_removed_enterprise_secret".to_string(), + ), ( "OPENAI_IDENTITY_TOKEN_FILE".to_string(), "identity-token-secret".to_string(), ), - ( - "PATH".to_string(), - format!("/repository-controlled:{}", std::env::var("PATH")?), - ), + ("PATH".to_string(), "/enterprise/bin".to_string()), + ("CORP_REGION".to_string(), "production".to_string()), + ("NPM_TOKEN".to_string(), "npm_enterprise_token".to_string()), ]), ..ShellEnvironmentPolicy::default() }; @@ -616,7 +653,10 @@ async fn snapshot_discovers_and_redacts_shell_initialized_credentials() -> Resul assert!(snapshot.contains("api.snapshot.example")); assert!(snapshot.contains("IDENTITY_SEEN=\"missing\"")); assert!(snapshot.contains("EXCLUDED_PARENT_HOME=\"missing\"")); - assert!(!snapshot.contains("STARTUP_PATH_OVERRIDE_SEEN=\"/repository-controlled\"")); + assert!(snapshot.contains("STARTUP_PATH_OVERRIDE_SEEN=\"/enterprise/bin\"")); + assert!(snapshot.contains("declare -x PATH=\"/enterprise/bin\"")); + assert!(snapshot.contains("STARTUP_CORP_REGION_SEEN=\"production\"")); + assert!(snapshot.contains("STARTUP_NPM_TOKEN_SEEN=\"npm_enterprise_token\"")); assert!(snapshot.contains("declare -rx HOMEBREW_GITHUB_API_TOKEN=\"${GITHUB_TOKEN-}\"")); validate_snapshot( @@ -671,6 +711,33 @@ async fn snapshot_discovers_and_redacts_shell_initialized_credentials() -> Resul .contains("EXCLUDED_PARENT_HOME") ); + let mut excluded_credential_broker = SnapshotCredentialBroker { + network_proxy: network_proxy.clone(), + shell_environment_policy: shell_environment_policy.clone(), + allow_login_shell: false, + }; + excluded_credential_broker + .shell_environment_policy + .exclude + .push(EnvironmentVariablePattern::new_case_insensitive("GH_TOKEN")); + let (_, excluded_credentials) = capture_snapshot( + &shell, + &dir.path().abs(), + Some(&excluded_credential_broker), + /*sandbox*/ None, + ) + .await?; + assert_eq!( + excluded_credentials.unwrap().unset_credential_keys, + [ + "AUTH_BUNDLE", + "AUTH_HEADER", + "GH_TOKEN", + "GITHUB_ENTERPRISE_TOKEN", + "UNSET_AUTH_HEADER", + ] + ); + let inherited_secret = "ghp_inherited_enterprise_secret"; let inherited_context = HashMap::from([( "GH_HOST".to_string(), @@ -720,12 +787,44 @@ async fn snapshot_discovers_and_redacts_shell_initialized_credentials() -> Resul assert!(!inherited_snapshot.contains(inherited_secret)); let snapshot_file = ShellSnapshotFile { path, credentials }; + let unset_credential_keys = snapshot_file + .credentials + .as_ref() + .unwrap() + .unset_credential_keys + .as_slice(); + assert_eq!( + unset_credential_keys, + &["GITHUB_ENTERPRISE_TOKEN", "UNSET_AUTH_HEADER"] + ); + let mut unset_policy = shell_environment_policy.clone(); + let mut unset_env = HashMap::new(); + for key in unset_credential_keys { + unset_env.insert(key.clone(), unset_policy.r#set.remove(key).unwrap()); + } + snapshot_file.restore_credentials(&mut unset_env, &unset_policy); + snapshot_file.restore_fail_open_aliases(&mut unset_env); + assert!( + unset_credential_keys + .iter() + .all(|key| !unset_env.contains_key(key)) + ); + let mut env = HashMap::from([("GH_HOST".to_string(), "github.example.com".to_string())]); + env.extend( + shell_environment_policy + .r#set + .iter() + .filter(|(key, _)| unset_credential_keys.contains(key)) + .map(|(key, value)| (key.clone(), value.clone())), + ); snapshot_file.restore_credentials(&mut env, &shell_environment_policy); for (key, value) in [ ("GH_TOKEN", "ghp_shell_only_secret"), ("GITHUB_TOKEN", "ghp_readonly_secret"), ("GH_ENTERPRISE_TOKEN", "ghp_enterprise_secret"), + ("GITHUB_ENTERPRISE_TOKEN", "ghp_removed_enterprise_secret"), + ("UNSET_AUTH_HEADER", "Bearer ghp_removed_enterprise_secret"), ("OPENAI_API_KEY", "sk-proj-snapshot-secret"), ] { assert_eq!(env.get(key).map(String::as_str), Some(value), "{key}"); @@ -754,13 +853,21 @@ async fn snapshot_discovers_and_redacts_shell_initialized_credentials() -> Resul assert_ne!(env["GITHUB_TOKEN"], "ghp_readonly_secret"); assert_ne!(env["GH_ENTERPRISE_TOKEN"], "ghp_enterprise_secret"); assert_ne!(env["OPENAI_API_KEY"], "sk-proj-snapshot-secret"); - let replay = Command::new("/bin/bash") - .arg("-c") - .arg( - ". \"$1\" && printf '%s\\n%s\\n%s' \"$HOMEBREW_GITHUB_API_TOKEN\" \"$AUTH_HEADER\" \"$AUTH_BUNDLE\"", - ) - .arg("snapshot") - .arg(snapshot_file.path().as_path()) + let snapshot_path = snapshot_file.path(); + prepare_brokered_shell_snapshot_env(&mut env, Some(&snapshot_path), &shell); + let replay_command = maybe_wrap_shell_lc_with_snapshot( + &shell.derive_exec_args( + "printf '%s\\n%s\\n%s' \"$HOMEBREW_GITHUB_API_TOKEN\" \"$AUTH_HEADER\" \"$AUTH_BUNDLE\"", + /*use_login_shell*/ false, + ), + &shell, + Some(&snapshot_path), + &shell_environment_policy.r#set, + &env, + &RuntimePathPrepends::default(), + ); + let replay = Command::new(&replay_command[0]) + .args(&replay_command[1..]) .env_clear() .envs(&env) .output()?; @@ -815,7 +922,7 @@ async fn snapshot_discovers_and_redacts_shell_initialized_credentials() -> Resul ), )?; let partially_filtered_path = dir.path().join("partially-filtered-snapshot.sh").abs(); - write_shell_snapshot( + let filtered_credentials = write_shell_snapshot( &shell, &partially_filtered_path, &dir.path().abs(), @@ -836,13 +943,74 @@ async fn snapshot_discovers_and_redacts_shell_initialized_credentials() -> Resul String::from_utf8(filtered_replay.stdout)?, "Bearer ghp_filtered_dummy\nunset" ); + let filtered_snapshot = ShellSnapshotFile { + path: partially_filtered_path, + credentials: filtered_credentials, + }; + let mut fail_open_env = HashMap::new(); + filtered_snapshot.restore_fail_open_aliases(&mut fail_open_env); + assert!(!fail_open_env.contains_key("AUTH_BUNDLE")); } + let posix_startup = dir.path().join("posix-startup.sh"); + std::fs::write( + &posix_startup, + "export GH_TOKEN='ghp_posix_shell_secret'\nexport POSIX_STARTUP_LOADED=1\n", + )?; + let posix_shell = Shell { + shell_type: ShellType::Sh, + shell_path: PathBuf::from("/bin/sh"), + }; + let posix_credential_broker = SnapshotCredentialBroker { + network_proxy: network_proxy.clone(), + shell_environment_policy: ShellEnvironmentPolicy { + r#set: HashMap::from([("ENV".to_string(), "$PWD/posix-startup.sh".to_string())]), + ..ShellEnvironmentPolicy::default() + }, + allow_login_shell: true, + }; + let posix_snapshot_path = dir.path().join("posix-snapshot.sh").abs(); + let posix_credentials = write_shell_snapshot( + &posix_shell, + &posix_snapshot_path, + &dir.path().abs(), + Some(&posix_credential_broker), + /*sandbox*/ None, + ) + .await? + .expect("brokered POSIX snapshot has credentials"); + let posix_snapshot = fs::read_to_string(&posix_snapshot_path).await?; + assert!(posix_snapshot.contains("POSIX_STARTUP_LOADED")); + assert!(!posix_snapshot.contains("ghp_posix_shell_secret")); + assert_eq!( + posix_credentials + .protected_startup_env + .as_deref() + .map(std::fs::canonicalize) + .transpose()?, + Some(std::fs::canonicalize(&posix_startup)?) + ); + + std::fs::write(&posix_startup, "export APPLICATION_SETTING=production\n")?; + let application_snapshot_path = dir.path().join("posix-application-snapshot.sh").abs(); + let application_credentials = write_shell_snapshot( + &posix_shell, + &application_snapshot_path, + &dir.path().abs(), + Some(&posix_credential_broker), + /*sandbox*/ None, + ) + .await? + .expect("brokered POSIX application snapshot has credentials"); + assert!(application_credentials.protected_startup_env.is_none()); + std::fs::write( &startup, "export GH_TOKEN='ghp_hidden_alias_secret'\n\ - export HIDDEN_HEADER=\"Bearer $GH_TOKEN\"\n\ - unset GH_TOKEN GITHUB_ENTERPRISE_TOKEN\n", + export HIDDEN_HEADER=\"prefix\\\"$GH_TOKEN\\\"suffix\"\n\ + export GITHUB_TOKEN='ghp_0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefgh'\n\ + export UNKNOWN_HEADER=\"Bearer $GITHUB_TOKEN\"\n\ + unset GH_TOKEN GITHUB_TOKEN GITHUB_ENTERPRISE_TOKEN\n", )?; let mut previously_brokered_env = HashMap::from([( "GH_TOKEN".to_string(), @@ -850,7 +1018,7 @@ async fn snapshot_discovers_and_redacts_shell_initialized_credentials() -> Resul )]); network_proxy.apply_to_env(&mut previously_brokered_env); let inherited_credential_broker = SnapshotCredentialBroker { - network_proxy, + network_proxy: network_proxy.clone(), shell_environment_policy: ShellEnvironmentPolicy { r#set: HashMap::from([trusted_startup]), ..ShellEnvironmentPolicy::default() @@ -858,7 +1026,7 @@ async fn snapshot_discovers_and_redacts_shell_initialized_credentials() -> Resul allow_login_shell: false, }; let inherited_path = dir.path().join("inherited-snapshot.sh").abs(); - write_shell_snapshot( + let inherited_credentials = write_shell_snapshot( &shell, &inherited_path, &dir.path().abs(), @@ -866,11 +1034,203 @@ async fn snapshot_discovers_and_redacts_shell_initialized_credentials() -> Resul /*sandbox*/ None, ) .await?; - assert!( - !fs::read_to_string(inherited_path) - .await? - .contains("ghp_hidden_alias_secret") + let inherited_snapshot = fs::read_to_string(&inherited_path).await?; + assert!(!inherited_snapshot.contains("ghp_hidden_alias_secret")); + assert!(!inherited_snapshot.contains("ghp_0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefgh")); + let inherited_replay = Command::new("/bin/bash") + .arg("-c") + .arg(". \"$1\" && printf '%s\\n%s' \"${HIDDEN_HEADER-unset}\" \"${UNKNOWN_HEADER-unset}\"") + .arg("snapshot") + .arg(inherited_path.as_path()) + .env_clear() + .output()?; + assert!(inherited_replay.status.success()); + assert_eq!(String::from_utf8(inherited_replay.stdout)?, "unset\nunset"); + + let snapshot_file = ShellSnapshotFile { + path: inherited_path, + credentials: inherited_credentials, + }; + let real_hidden_header = "prefix\"ghp_hidden_alias_secret\"suffix"; + let real_header = "Bearer ghp_0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefgh"; + let mut brokered_env = HashMap::new(); + snapshot_file.restore_credentials( + &mut brokered_env, + &inherited_credential_broker.shell_environment_policy, ); + inherited_credential_broker + .network_proxy + .apply_to_env(&mut brokered_env); + let dummy_header = brokered_env["UNKNOWN_HEADER"].clone(); + let dummy_hidden_header = brokered_env["HIDDEN_HEADER"].clone(); + assert_ne!(dummy_header, real_header); + assert_ne!(dummy_hidden_header, real_hidden_header); + snapshot_file.restore_fail_open_aliases(&mut brokered_env); + assert_eq!( + brokered_env.get("UNKNOWN_HEADER").map(String::as_str), + Some(dummy_header.as_str()) + ); + assert_eq!( + brokered_env.get("HIDDEN_HEADER").map(String::as_str), + Some(dummy_hidden_header.as_str()) + ); + + let mut fail_open_env = HashMap::new(); + snapshot_file.restore_credentials( + &mut fail_open_env, + &inherited_credential_broker.shell_environment_policy, + ); + snapshot_file.restore_fail_open_aliases(&mut fail_open_env); + assert_eq!( + fail_open_env.get("UNKNOWN_HEADER").map(String::as_str), + Some(real_header) + ); + assert_eq!( + fail_open_env.get("HIDDEN_HEADER").map(String::as_str), + Some(real_hidden_header) + ); + + std::fs::write( + &startup, + "export GH_TOKEN='ghp_hidden_alias_secret'\n\ + credential_function() { printf '%s' 'ghp_hidden_alias_secret'; }\n\ + unset GH_TOKEN\n", + )?; + let residual_credential = capture_snapshot( + &shell, + &dir.path().abs(), + Some(&inherited_credential_broker), + /*sandbox*/ None, + ) + .await; + assert!( + residual_credential.is_err(), + "snapshot with a credential-bearing shell function must be rejected" + ); + + std::fs::write( + &startup, + "unset PATH\nset -u\nexport GH_TOKEN='ghp_unset_path_secret'\n", + )?; + let (unset_path_snapshot, _) = capture_snapshot( + &shell, + &dir.path().abs(), + Some(&inherited_credential_broker), + /*sandbox*/ None, + ) + .await?; + assert!(!unset_path_snapshot.contains("declare -x PATH=")); + assert!(!unset_path_snapshot.contains("ghp_unset_path_secret")); + + for real in [ + "ghp_abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGH", + "opaque_enterprise_credential", + "0123456789abcdef0123456789abcdef01234567", + ] { + std::fs::write( + &startup, + format!( + "unset GH_HOST\nexport GH_ENTERPRISE_TOKEN='{real}'\nexport AUTH_HEADER=\"Bearer $GH_ENTERPRISE_TOKEN\"\n" + ), + )?; + let (snapshot, credentials) = capture_snapshot( + &shell, + &dir.path().abs(), + Some(&inherited_credential_broker), + /*sandbox*/ None, + ) + .await?; + assert!(!snapshot.contains(real)); + let snapshot_file = ShellSnapshotFile { + path: dir.path().join("hostless-snapshot.sh").abs(), + credentials, + }; + let policy = &inherited_credential_broker.shell_environment_policy; + let mut restored = HashMap::new(); + snapshot_file.restore_credentials(&mut restored, policy); + network_proxy.apply_to_env(&mut restored); + snapshot_file.restore_fail_open_aliases(&mut restored); + assert_eq!( + restored.get("GH_ENTERPRISE_TOKEN").map(String::as_str), + Some(real) + ); + + let mut excluded = policy.clone(); + excluded + .exclude + .push(EnvironmentVariablePattern::new_case_insensitive( + "GH_ENTERPRISE_TOKEN", + )); + let mut filtered = HashMap::new(); + snapshot_file.restore_credentials(&mut filtered, &excluded); + assert!(!filtered.contains_key("GH_ENTERPRISE_TOKEN")); + + let mut overridden = policy.clone(); + overridden + .r#set + .insert("GH_ENTERPRISE_TOKEN".to_string(), String::new()); + let mut empty = overridden.r#set.clone(); + snapshot_file.restore_credentials(&mut empty, &overridden); + assert_eq!( + empty.get("GH_ENTERPRISE_TOKEN").map(String::as_str), + Some("") + ); + + let policies = if real.starts_with("ghp_") { + vec![excluded, overridden] + } else { + vec![policy.clone(), excluded, overridden] + }; + for policy in policies { + let broker = SnapshotCredentialBroker { + network_proxy: network_proxy.clone(), + shell_environment_policy: policy.clone(), + allow_login_shell: false, + }; + let (snapshot, credentials) = capture_snapshot( + &shell, + &dir.path().abs(), + Some(&broker), + /*sandbox*/ None, + ) + .await?; + assert!(!snapshot.contains(real)); + let snapshot_file = ShellSnapshotFile { + path: dir.path().join("hostless-alias-snapshot.sh").abs(), + credentials, + }; + std::fs::write(&snapshot_file.path, snapshot)?; + let mut env = HashMap::from([("AUTH_HEADER".to_string(), format!("Bearer {real}"))]); + snapshot_file.restore_credentials(&mut env, &policy); + network_proxy.apply_to_env(&mut env); + snapshot_file.restore_fail_open_aliases(&mut env); + let replayed = Command::new("/bin/bash") + .args([ + "-c", + ". \"$1\"; printf '%s' \"${AUTH_HEADER-}\"", + "snapshot", + ]) + .arg(snapshot_file.path.as_path()) + .env_clear() + .envs(env) + .output()?; + assert!(replayed.status.success()); + let expected = + if policy.exclude.is_empty() && !policy.r#set.contains_key("GH_ENTERPRISE_TOKEN") { + format!("Bearer {real}") + } else { + String::new() + }; + assert_eq!(String::from_utf8(replayed.stdout)?, expected); + } + + restored.insert("GH_HOST".to_string(), "github.later.example".to_string()); + network_proxy.apply_to_env(&mut restored); + assert_ne!( + restored.get("GH_ENTERPRISE_TOKEN").map(String::as_str), + Some(real) + ); + } Ok(()) } diff --git a/codex-rs/core/src/tools/runtimes/mod.rs b/codex-rs/core/src/tools/runtimes/mod.rs index b2c0300ac9..727ff73917 100644 --- a/codex-rs/core/src/tools/runtimes/mod.rs +++ b/codex-rs/core/src/tools/runtimes/mod.rs @@ -27,6 +27,7 @@ pub(crate) use codex_network_proxy::is_managed_proxy_env_var; pub(crate) use codex_network_proxy::strip_managed_proxy_env; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::shell_environment::is_non_inheritable_env_var; +use codex_shell_command::shell_snapshot::posix_env_path_expansion_function; use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; use std::path::Path; @@ -35,6 +36,12 @@ pub(crate) mod apply_patch; pub(crate) mod unified_exec; pub(crate) mod zsh_fork; +const SNAPSHOT_ORIGINAL_BASH_ENV_ENV_KEY: &str = "CODEX_NETWORK_PROXY_SNAPSHOT_ORIGINAL_BASH_ENV"; +const SNAPSHOT_ORIGINAL_POSIX_ENV_ENV_KEY: &str = "CODEX_NETWORK_PROXY_SNAPSHOT_ORIGINAL_POSIX_ENV"; +const SNAPSHOT_ORIGINAL_ZDOTDIR_ENV_KEY: &str = "CODEX_NETWORK_PROXY_SNAPSHOT_ORIGINAL_ZDOTDIR"; +const SNAPSHOT_BROKERED_VALUE_ENV_PREFIX: &str = "CODEX_NETWORK_PROXY_SNAPSHOT_BROKERED_VALUE_"; +const SNAPSHOT_BROKERED_UNSET_ENV_PREFIX: &str = "CODEX_NETWORK_PROXY_SNAPSHOT_BROKERED_UNSET_"; + pub(crate) fn exec_env_for_sandbox_permissions( env: &HashMap, sandbox_permissions: SandboxPermissions, @@ -204,13 +211,57 @@ fn prepare_powershell_command_for_elevated_windows_sandbox_with_fallback( pub(crate) fn prepare_brokered_shell_snapshot_env( env: &mut HashMap, shell_snapshot: Option<&AbsolutePathBuf>, + shell: &Shell, ) { if shell_snapshot.is_some() && env .get(CREDENTIAL_BROKER_ACTIVE_ENV_KEY) .is_some_and(|active| active == "1") { - env.remove("BASH_ENV"); + if !cfg!(windows) { + env.retain(|key, _| { + !key.starts_with(SNAPSHOT_BROKERED_VALUE_ENV_PREFIX) + && !key.starts_with(SNAPSHOT_BROKERED_UNSET_ENV_PREFIX) + }); + for key in codex_network_proxy::brokered_credential_value_env_keys(env) { + if is_valid_shell_variable_name(&key) + && let Some(value) = env.get(&key).cloned() + { + env.insert(format!("{SNAPSHOT_BROKERED_VALUE_ENV_PREFIX}{key}"), value); + } + } + for key in codex_network_proxy::brokered_credential_marker_env_keys(env) { + if !is_valid_shell_variable_name(&key) { + continue; + } + if let Some(value) = env.get(&key).cloned() { + env.entry(format!("{SNAPSHOT_BROKERED_VALUE_ENV_PREFIX}{key}")) + .or_insert(value); + } else { + env.insert( + format!("{SNAPSHOT_BROKERED_UNSET_ENV_PREFIX}{key}"), + "1".to_string(), + ); + } + } + } + let bash_env = env.remove("BASH_ENV"); + if !cfg!(windows) + && let Some(bash_env) = bash_env + && !bash_env.is_empty() + { + env.insert(SNAPSHOT_ORIGINAL_BASH_ENV_ENV_KEY.to_string(), bash_env); + } else { + env.remove(SNAPSHOT_ORIGINAL_BASH_ENV_ENV_KEY); + } + if shell.shell_type == ShellType::Zsh { + if let Some(zdotdir) = env.remove("ZDOTDIR") { + env.insert(SNAPSHOT_ORIGINAL_ZDOTDIR_ENV_KEY.to_string(), zdotdir); + } else { + env.remove(SNAPSHOT_ORIGINAL_ZDOTDIR_ENV_KEY); + } + env.insert("ZDOTDIR".to_string(), "/dev/null".to_string()); + } } } @@ -320,18 +371,136 @@ pub(crate) fn maybe_wrap_shell_lc_with_snapshot( ], ); let (proxy_captures, proxy_exports) = build_proxy_env_exports(env); + let (env_captures, replayed_startup_capture, env_exports) = if brokered { + let bash_env_key = SNAPSHOT_ORIGINAL_BASH_ENV_ENV_KEY; + let posix_env_key = SNAPSHOT_ORIGINAL_POSIX_ENV_ENV_KEY; + let startup_env_key = + if session_shell.shell_type == ShellType::Sh && env.contains_key(posix_env_key) { + posix_env_key + } else { + bash_env_key + }; + let alternate_startup_env_key = if startup_env_key == posix_env_key { + bash_env_key + } else { + posix_env_key + }; + let zdotdir_key = SNAPSHOT_ORIGINAL_ZDOTDIR_ENV_KEY; + let expand_env_function = posix_env_path_expansion_function(); + ( + format!( + r#"__CODEX_SNAPSHOT_ORIGINAL_ENV_SET="${{ENV+x}}" +__CODEX_SNAPSHOT_ORIGINAL_ENV="${{ENV-}}" +{expand_env_function} +__codex_snapshot_expand_env_with_zdotdir() ( + if [ -n "${{{zdotdir_key}+x}}" ]; then + export ZDOTDIR="${{{zdotdir_key}}}" + elif [ -n "${{ZSH_VERSION-}}" ] && [ "${{ZDOTDIR-}}" = /dev/null ]; then + unset ZDOTDIR + fi + __codex_snapshot_expand_env "$1" +) +__CODEX_SNAPSHOT_PROTECTED_ENV=$( + __codex_snapshot_expand_env_with_zdotdir "${{{startup_env_key}-}}" +) +__CODEX_SNAPSHOT_ALTERNATE_PROTECTED_ENV=$( + __codex_snapshot_expand_env_with_zdotdir "${{{alternate_startup_env_key}-}}" +)"# + ), + "__CODEX_SNAPSHOT_REPLAYED_BASH_ENV=\"${BASH_ENV-}\"".to_string(), + format!( + r#"__CODEX_SNAPSHOT_CURRENT_ENV=$( + __codex_snapshot_expand_env_with_zdotdir "${{ENV-}}" +) +__CODEX_SNAPSHOT_ORIGINAL_EXPANDED_ENV=$( + __codex_snapshot_expand_env_with_zdotdir "$__CODEX_SNAPSHOT_ORIGINAL_ENV" +) +__CODEX_SNAPSHOT_REPLAYED_PROTECTED_ENV=$( + __codex_snapshot_expand_env_with_zdotdir "$__CODEX_SNAPSHOT_REPLAYED_BASH_ENV" +) +unset -f __codex_snapshot_expand_env __codex_snapshot_expand_env_with_zdotdir +__codex_snapshot_env_is_protected() ( + for __codex_protected_env in \ + "$__CODEX_SNAPSHOT_PROTECTED_ENV" \ + "$__CODEX_SNAPSHOT_ALTERNATE_PROTECTED_ENV" \ + "$__CODEX_SNAPSHOT_REPLAYED_PROTECTED_ENV"; do + if [ -n "$__codex_protected_env" ] && + {{ [ "$1" = "$__codex_protected_env" ] || + [ "$1" -ef "$__codex_protected_env" ] 2>/dev/null; }}; then + return 0 + fi + done + return 1 +) +if __codex_snapshot_env_is_protected "$__CODEX_SNAPSHOT_CURRENT_ENV"; then + if [ -n "$__CODEX_SNAPSHOT_ORIGINAL_ENV_SET" ] && + ! __codex_snapshot_env_is_protected "$__CODEX_SNAPSHOT_ORIGINAL_EXPANDED_ENV"; then + builtin export ENV="$__CODEX_SNAPSHOT_ORIGINAL_ENV" 2>/dev/null || + command export ENV="$__CODEX_SNAPSHOT_ORIGINAL_ENV" || exit 1 + [ "${{ENV-}}" = "$__CODEX_SNAPSHOT_ORIGINAL_ENV" ] || exit 1 + else + builtin unset ENV 2>/dev/null || command unset ENV || exit 1 + [ -z "${{ENV+x}}" ] || exit 1 + fi +fi +unset -f __codex_snapshot_env_is_protected +unset __CODEX_SNAPSHOT_ORIGINAL_ENV_SET __CODEX_SNAPSHOT_ORIGINAL_ENV \ + __CODEX_SNAPSHOT_PROTECTED_ENV __CODEX_SNAPSHOT_ALTERNATE_PROTECTED_ENV \ + __CODEX_SNAPSHOT_REPLAYED_BASH_ENV __CODEX_SNAPSHOT_REPLAYED_PROTECTED_ENV \ + __CODEX_SNAPSHOT_CURRENT_ENV __CODEX_SNAPSHOT_ORIGINAL_EXPANDED_ENV \ + {bash_env_key} {posix_env_key}"# + ), + ) + } else { + (String::new(), String::new(), String::new()) + }; + let zsh_startup_exports = if brokered && session_shell.shell_type == ShellType::Zsh { + let key = SNAPSHOT_ORIGINAL_ZDOTDIR_ENV_KEY; + format!( + "if [ -n \"${{{key}+x}}\" ]; then\n export ZDOTDIR=\"${{{key}}}\"\nelif [ \"${{ZDOTDIR-}}\" = /dev/null ]; then\n unset ZDOTDIR\nfi\nunset {key}" + ) + } else { + String::new() + }; + // Zsh always reads the global zshenv, even with `-f`. Keep private copies of child-visible + // dummy values until the command shell has finished startup, then remove the copies. + let outer_brokered_credential_exports = if brokered { + build_brokered_credential_exports(env, /*remove_copies*/ false) + } else { + String::new() + }; + let inner_brokered_credential_exports = if brokered { + build_brokered_credential_exports(env, /*remove_copies*/ true) + } else { + String::new() + }; + let original_script = if !inner_brokered_credential_exports.is_empty() { + format!("{inner_brokered_credential_exports}\n{}", command[2]) + } else { + command[2].clone() + }; let runtime_path_prepend_exports = runtime_path_prepends.shell_exports_after_snapshot(explicit_env_overrides); - let override_captures = join_shell_blocks([override_captures, proxy_captures]); + let override_captures = join_shell_blocks([ + outer_brokered_credential_exports.clone(), + override_captures, + proxy_captures, + env_captures, + ]); let override_exports = join_shell_blocks([ + outer_brokered_credential_exports.clone(), + replayed_startup_capture, override_exports, proxy_exports, runtime_path_prepend_exports, + env_exports, + zsh_startup_exports, + outer_brokered_credential_exports, ]); let run_original = if reuse_session_shell { - command[2].clone() + original_script } else { - let original_script = shell_single_quote(&command[2]); + let original_script = shell_single_quote(&original_script); format!("exec '{original_shell}' {original_shell_flag} '{original_script}'{trailing_args}") }; let rewritten_script = if override_exports.is_empty() { @@ -358,6 +527,60 @@ pub(crate) fn maybe_wrap_shell_lc_with_snapshot( rewritten } +fn build_brokered_credential_exports(env: &HashMap, remove_copies: bool) -> String { + let mut value_copies = env + .keys() + .filter_map(|copy_key| { + let key = copy_key.strip_prefix(SNAPSHOT_BROKERED_VALUE_ENV_PREFIX)?; + is_valid_shell_variable_name(key).then_some((key, copy_key)) + }) + .collect::>(); + value_copies.sort_unstable(); + let value_exports = value_copies + .into_iter() + .map(|(key, copy_key)| { + let restore = format!( + "if [ -z \"${{{copy_key}+x}}\" ]; then exit 1; fi\nif [ -n \"${{{key}+x}}\" ] && [ -n \"${{{key}}}\" ] && [ \"${{{key}}}\" != \"${{{copy_key}}}\" ]; then export {key}=\"${{{copy_key}}}\" || exit 1; fi\nif [ -n \"${{{key}+x}}\" ] && [ -n \"${{{key}}}\" ] && [ \"${{{key}}}\" != \"${{{copy_key}}}\" ]; then exit 1; fi" + ); + if remove_copies { + format!("{restore}\nunset {copy_key} || exit 1") + } else { + restore + } + }) + .collect::>() + .join("\n"); + let mut unset_markers = env + .keys() + .filter_map(|marker_key| { + let key = marker_key.strip_prefix(SNAPSHOT_BROKERED_UNSET_ENV_PREFIX)?; + is_valid_shell_variable_name(key).then_some((key, marker_key)) + }) + .collect::>(); + unset_markers.sort_unstable(); + let unset_exports = unset_markers + .into_iter() + .map(|(key, marker_key)| { + let restore = format!( + "if [ -z \"${{{marker_key}+x}}\" ]; then exit 1; fi\nif [ -n \"${{{key}+x}}\" ] && [ -n \"${{{key}}}\" ]; then unset {key} || exit 1; fi\nif [ -n \"${{{key}+x}}\" ] && [ -n \"${{{key}}}\" ]; then exit 1; fi" + ); + if remove_copies { + format!("{restore}\nunset {marker_key} || exit 1") + } else { + restore + } + }) + .collect::>() + .join("\n"); + let exports = join_shell_blocks([value_exports, unset_exports]); + if exports.is_empty() { + return exports; + } + format!( + "case $- in\n *x*) __CODEX_SNAPSHOT_BROKER_XTRACE=1; set +x ;;\n *) __CODEX_SNAPSHOT_BROKER_XTRACE= ;;\nesac\n{exports}\nif [ -n \"$__CODEX_SNAPSHOT_BROKER_XTRACE\" ]; then\n unset __CODEX_SNAPSHOT_BROKER_XTRACE\n set -x\nelse\n unset __CODEX_SNAPSHOT_BROKER_XTRACE\nfi" + ) +} + fn build_override_exports( explicit_env_overrides: &HashMap, restore_even_when_absent: &[&str], @@ -448,7 +671,7 @@ fn build_override_exports_for_keys(variable_prefix: &str, keys: &[&str]) -> (Str let set_var = format!("{variable_prefix}_SET_{idx}"); let value_var = format!("{variable_prefix}_{idx}"); format!( - "if [ -n \"${{{set_var}}}\" ]; then\n if [ -z \"${{{key}+x}}\" ] || [ \"${{{key}-}}\" != \"${{{value_var}}}\" ]; then export {key}=\"${{{value_var}}}\"; else export {key}; fi\nelse builtin unset {key} 2>/dev/null || command unset {key}; fi" + "if [ -n \"${{{set_var}}}\" ]; then\n if [ -z \"${{{key}+x}}\" ] || [ \"${{{key}-}}\" != \"${{{value_var}}}\" ]; then export {key}=\"${{{value_var}}}\"; else export {key}; fi\nelse builtin unset {key} 2>/dev/null || command unset {key}; fi\nbuiltin unset {set_var} {value_var} 2>/dev/null || command unset {set_var} {value_var}" ) }) .collect::>() @@ -617,7 +840,6 @@ mod prepare_powershell_command_tests { assert_eq!(rewritten, command); } - #[test] fn local_elevated_powershell_uses_discovered_fallback() { let command = vec![ diff --git a/codex-rs/core/src/tools/runtimes/mod_tests.rs b/codex-rs/core/src/tools/runtimes/mod_tests.rs index 734c07dbb4..8d0d9b5f8b 100644 --- a/codex-rs/core/src/tools/runtimes/mod_tests.rs +++ b/codex-rs/core/src/tools/runtimes/mod_tests.rs @@ -27,6 +27,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; use core_test_support::PathBufExt; use pretty_assertions::assert_eq; +use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; use std::process::Command; use std::sync::Arc; @@ -461,6 +462,14 @@ fn maybe_wrap_shell_lc_with_snapshot_reuses_brokered_session_zsh() { std::fs::write(&snapshot_path, "# Snapshot file\n").expect("write snapshot"); let (session_shell, shell_snapshot) = shell_with_snapshot(ShellType::Zsh, "/bin/zsh", snapshot_path.abs()); + let copy_key = format!("{SNAPSHOT_BROKERED_VALUE_ENV_PREFIX}OPENAI_API_KEY"); + let env = HashMap::from([ + ( + CREDENTIAL_BROKER_ACTIVE_ENV_KEY.to_string(), + "1".to_string(), + ), + (copy_key.clone(), "dummy-openai-key".to_string()), + ]); for (flag, expected_flag) in [("-c", "-fc"), ("-lc", "-lfc")] { let command = vec![ "/bin/zsh".to_string(), @@ -469,10 +478,6 @@ fn maybe_wrap_shell_lc_with_snapshot_reuses_brokered_session_zsh() { "arg0".to_string(), "arg1".to_string(), ]; - let env = HashMap::from([( - CREDENTIAL_BROKER_ACTIVE_ENV_KEY.to_string(), - "1".to_string(), - )]); let rewritten = maybe_wrap_shell_lc_with_snapshot( &command, @@ -490,6 +495,7 @@ fn maybe_wrap_shell_lc_with_snapshot_reuses_brokered_session_zsh() { shell_single_quote(&shell_snapshot.to_string_lossy()) ))); assert!(rewritten[2].ends_with(&command[2])); + assert!(rewritten[2].contains(&format!("unset {copy_key} || exit 1"))); assert!(!rewritten[2].contains("exec '/bin/zsh'")); assert_eq!(rewritten[3..], command[3..]); } @@ -855,7 +861,8 @@ async fn snapshot_wrapper_replays_dummy_and_preserves_unbrokered_credentials() - std::fs::write( &snapshot, format!( - "# Snapshot file\ncorp_auth() {{ printf '%s\\n%s\\n%s' \"$OPENAI_API_KEY\" \"${{GITHUB_TOKEN-unset}}\" \"$1\"; }}\nexport OPENAI_API_KEY='stale'\nexport GITHUB_TOKEN='ghp_snapshot_only'\nexport GH_HOST='github.example.com'\nexport BASH_ENV='{}'\nexport ZDOTDIR='{}'\n", + "# Snapshot file\ncorp_auth() {{ printf '%s\\n%s\\n%s' \"$OPENAI_API_KEY\" \"${{GITHUB_TOKEN-unset}}\" \"$1\"; }}\nexport OPENAI_API_KEY='stale'\nexport GITHUB_TOKEN='ghp_snapshot_only'\nexport GH_HOST='github.example.com'\nexport BASH_ENV='{}'\nexport ENV='{}'\nexport ZDOTDIR='{}'\n", + startup.display(), startup.display(), dir.path().display() ), @@ -866,13 +873,27 @@ async fn snapshot_wrapper_replays_dummy_and_preserves_unbrokered_credentials() - "OPENAI_API_KEY".to_string(), "sk-proj-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_".to_string(), ), + ( + "AUTH_HEADER".to_string(), + "Bearer sk-proj-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_" + .to_string(), + ), ("GITHUB_TOKEN".to_string(), String::new()), ("BASH_ENV".to_string(), startup.display().to_string()), + ("ENV".to_string(), startup.display().to_string()), ]); proxy.apply_to_env(&mut env); - prepare_brokered_shell_snapshot_env(&mut env, Some(&snapshot)); - assert!(!env.contains_key("BASH_ENV")); let dummy = env["OPENAI_API_KEY"].clone(); + let dummy_alias = env["AUTH_HEADER"].clone(); + let mut alias_only_env = env.clone(); + alias_only_env.remove("OPENAI_API_KEY"); + let mut empty_canonical_env = env.clone(); + empty_canonical_env.insert("OPENAI_API_KEY".to_string(), String::new()); + let mut absent_alias_env = env.clone(); + absent_alias_env.remove("AUTH_HEADER"); + prepare_brokered_shell_snapshot_env(&mut env, Some(&snapshot), &shell); + assert!(!env.contains_key("BASH_ENV")); + assert_eq!(env.get("ENV"), Some(&startup.display().to_string())); let command = vec![ "/bin/bash".to_string(), "-c".to_string(), @@ -900,6 +921,341 @@ async fn snapshot_wrapper_replays_dummy_and_preserves_unbrokered_credentials() - format!("{dummy}\nghp_snapshot_only\ngithub.example.com") ); + let (posix_shell, _) = shell_with_snapshot(ShellType::Sh, "/bin/sh", snapshot.clone()); + let no_prepends = RuntimePathPrepends::default(); + let wrap_brokered_snapshot = |shell: &Shell, + snapshot: &AbsolutePathBuf, + env: &HashMap, + prepends: &RuntimePathPrepends| { + maybe_wrap_shell_lc_with_snapshot( + &[ + shell.shell_path.to_string_lossy().into_owned(), + if shell.shell_type == ShellType::Sh { + "-lc" + } else { + "-c" + } + .to_string(), + "/bin/sh -ic 'printf \"%s|%s\" \"$OPENAI_API_KEY\" \"${ENV-unset}\"'".to_string(), + ], + shell, + Some(snapshot), + &HashMap::new(), + env, + prepends, + ) + }; + let wrap_snapshot = |snapshot: &AbsolutePathBuf, env: &HashMap| { + wrap_brokered_snapshot(&posix_shell, snapshot, env, &no_prepends) + }; + let nested = wrap_snapshot(&snapshot, &env); + let run_nested = + |command: &[String], env: &HashMap| -> anyhow::Result { + let output = Command::new(&command[0]) + .args(&command[1..]) + .current_dir(dir.path()) + .env_clear() + .envs(env) + .output()?; + anyhow::ensure!(output.status.success(), "nested shell failed: {output:?}"); + Ok(String::from_utf8(output.stdout)?) + }; + let posix_startup = dir.path().join("posix-startup.sh"); + std::fs::write(&posix_startup, "export OPENAI_API_KEY='sk-posix-secret'\n")?; + let posix_snapshot = dir.path().join("posix-startup-snapshot.sh").abs(); + std::fs::write( + &posix_snapshot, + format!("export ENV='{}'\n", posix_startup.display()), + )?; + let mut posix_startup_env = env.clone(); + posix_startup_env.insert( + SNAPSHOT_ORIGINAL_POSIX_ENV_ENV_KEY.to_string(), + posix_startup.display().to_string(), + ); + let distinct = wrap_snapshot(&posix_snapshot, &posix_startup_env); + assert_eq!( + run_nested(&distinct, &posix_startup_env)?, + format!("{dummy}|unset") + ); + posix_startup_env.remove(SNAPSHOT_ORIGINAL_BASH_ENV_ENV_KEY); + posix_startup_env.insert("ENV".to_string(), posix_startup.display().to_string()); + let inherited = wrap_snapshot(&posix_snapshot, &posix_startup_env); + assert_eq!( + run_nested(&inherited, &posix_startup_env)?, + format!("{dummy}|unset") + ); + + let wrap_bash_snapshot = |snapshot: &AbsolutePathBuf, env: &HashMap| { + wrap_brokered_snapshot(&shell, snapshot, env, &no_prepends) + }; + let readonly_snapshot = dir.path().join("readonly-env-snapshot.sh").abs(); + for initial_env in [None, Some("production"), Some(startup.to_str().unwrap())] { + let mut case_env = env.clone(); + case_env.remove("ENV"); + if let Some(value) = initial_env { + case_env.insert("ENV".to_string(), value.to_string()); + } + for (value, succeeds) in [(startup.to_str().unwrap(), false), ("production", true)] { + std::fs::write( + &readonly_snapshot, + format!("export ENV='{value}'\nreadonly ENV\n"), + )?; + for command_shell in [&shell, &posix_shell] { + let command = wrap_brokered_snapshot( + command_shell, + &readonly_snapshot, + &case_env, + &no_prepends, + ); + let output = Command::new(&command[0]) + .args(&command[1..]) + .current_dir(dir.path()) + .env_clear() + .envs(&case_env) + .output()?; + assert_eq!(output.status.success(), succeeds, "{output:?}"); + assert_eq!( + String::from_utf8(output.stdout)?, + if succeeds { + format!("{dummy}|production") + } else { + String::new() + } + ); + } + } + } + let prepended_path_snapshot = dir.path().join("prepended-path-snapshot.sh").abs(); + std::fs::write( + &prepended_path_snapshot, + "export ENV='${PATH%%:*}/startup.sh'\nexport PATH='/safe'\n", + )?; + let mut prepended_env = env.clone(); + prepended_env.insert("PATH".to_string(), "/safe".to_string()); + prepended_env.insert("ENV".to_string(), "${PATH%%:*}/startup.sh".to_string()); + let mut prepends = RuntimePathPrepends::default(); + prepends.prepend(&mut prepended_env, dir.path()); + let command = + wrap_brokered_snapshot(&shell, &prepended_path_snapshot, &prepended_env, &prepends); + assert_eq!( + run_nested(&command, &prepended_env)?, + format!("{dummy}|unset") + ); + let relative_startup_snapshot = dir.path().join("relative-startup-snapshot.sh").abs(); + let startup_path = startup.display().to_string(); + let mut expanded_startup_env = env.clone(); + expanded_startup_env.extend([ + ("SNAPSHOT_STARTUP_FILE".to_string(), startup_path.clone()), + ("ORIGINAL_STARTUP_FILE".to_string(), startup_path.clone()), + ( + "SNAPSHOT_APPLICATION_FILE".to_string(), + "/safe-application.env".to_string(), + ), + ( + SNAPSHOT_ORIGINAL_BASH_ENV_ENV_KEY.to_string(), + "$SNAPSHOT_STARTUP_FILE".to_string(), + ), + ]); + let application_script = + format!("export ENV='{startup_path}'\nexport SNAPSHOT_APPLICATION_FILE='{startup_path}'\n"); + let changed_startup_script = "export ENV='$ORIGINAL_STARTUP_FILE'\nexport SNAPSHOT_STARTUP_FILE='/different-startup.sh'\n"; + let strict_script = format!("set -eu\nexport ENV='{startup_path}'\n"); + for (original_env, script, expected_env) in [ + ( + startup_path.as_str(), + "export ENV='./startup.sh'\n", + "unset", + ), + ( + startup_path.as_str(), + "export ENV='$SNAPSHOT_STARTUP_FILE'\n", + "unset", + ), + ( + "$SNAPSHOT_APPLICATION_FILE", + application_script.as_str(), + "unset", + ), + ( + "${SNAPSHOT_APPLICATION_FILE}", + application_script.as_str(), + "unset", + ), + (startup_path.as_str(), changed_startup_script, "unset"), + ( + "$OPTIONAL_APPLICATION_FILE", + strict_script.as_str(), + "$OPTIONAL_APPLICATION_FILE", + ), + ] { + let mut case_env = expanded_startup_env.clone(); + case_env.insert("ENV".to_string(), original_env.to_string()); + std::fs::write(&relative_startup_snapshot, script)?; + let command = wrap_bash_snapshot(&relative_startup_snapshot, &case_env); + assert_eq!( + run_nested(&command, &case_env)?, + format!("{dummy}|{expected_env}") + ); + } + + for bash_env in ["", "$OPTIONAL_STARTUP"] { + let mut case_env = env.clone(); + case_env.insert("BASH_ENV".to_string(), bash_env.to_string()); + case_env.insert("ENV".to_string(), String::new()); + prepare_brokered_shell_snapshot_env(&mut case_env, Some(&snapshot), &shell); + let command = wrap_bash_snapshot(&snapshot, &case_env); + assert_eq!(run_nested(&command, &case_env)?, format!("{dummy}|")); + } + + let mut expanded_env = env.clone(); + expanded_env.insert("ENV".to_string(), "$SNAPSHOT_STARTUP_FILE".to_string()); + expanded_env.insert( + "SNAPSHOT_STARTUP_FILE".to_string(), + startup.display().to_string(), + ); + assert_eq!( + run_nested(&nested, &expanded_env)?, + format!("{dummy}|unset") + ); + expanded_env.insert("ENV".to_string(), "${SNAPSHOT_STARTUP_FILE}".to_string()); + assert_eq!( + run_nested(&nested, &expanded_env)?, + format!("{dummy}|unset") + ); + + let injection = "\"; printf CODEX_ENV_INJECTED; __codex_env_file=\""; + expanded_env.insert("ENV".to_string(), injection.to_string()); + let startup_value_snapshot = dir.path().join("startup-value-snapshot.sh").abs(); + std::fs::write( + &startup_value_snapshot, + format!("export ENV='{injection}'\n"), + )?; + let expanded_command = wrap_snapshot(&startup_value_snapshot, &expanded_env); + assert_eq!( + run_nested(&expanded_command, &expanded_env)?, + format!("{dummy}|{injection}") + ); + + std::fs::write(&startup_value_snapshot, "export ENV='~/startup.sh'\n")?; + let bash_posix_path = dir.path().join("sh"); + std::os::unix::fs::symlink("/bin/bash", &bash_posix_path)?; + let bash_posix_path = bash_posix_path.display().to_string(); + let (bash_posix_shell, _) = + shell_with_snapshot(ShellType::Sh, &bash_posix_path, snapshot.clone()); + let bash_posix_command = vec![ + bash_posix_path.clone(), + "-c".to_string(), + format!( + "'{bash_posix_path}' -ic 'printf \"%s|%s\" \"$OPENAI_API_KEY\" \"${{ENV-unset}}\"'" + ), + ]; + let mut tilde_env = env.clone(); + tilde_env.insert("ENV".to_string(), "~/startup.sh".to_string()); + tilde_env.insert("HOME".to_string(), dir.path().display().to_string()); + let bash_posix_command = maybe_wrap_shell_lc_with_snapshot( + &bash_posix_command, + &bash_posix_shell, + Some(&startup_value_snapshot), + &HashMap::new(), + &tilde_env, + &RuntimePathPrepends::default(), + ); + assert_eq!( + run_nested(&bash_posix_command, &tilde_env)?, + format!("{dummy}|unset") + ); + + #[cfg(target_os = "macos")] + { + expanded_env.insert("ENV".to_string(), "~/startup.sh".to_string()); + expanded_env.insert("HOME".to_string(), dir.path().display().to_string()); + assert_eq!( + run_nested(&nested, &expanded_env)?, + format!("{dummy}|unset") + ); + } + + std::fs::create_dir(dir.path().join("production"))?; + let mut ordinary_env = env.clone(); + ordinary_env.insert("ENV".to_string(), "production".to_string()); + assert_eq!( + run_nested(&nested, &ordinary_env)?, + format!("{dummy}|production") + ); + + let application_env_file = dir.path().join(".env"); + std::fs::write(&application_env_file, "APPLICATION_SETTING=production\n")?; + ordinary_env.insert( + "ENV".to_string(), + application_env_file.display().to_string(), + ); + let application_command = vec![ + "/bin/bash".to_string(), + "-c".to_string(), + "printf '%s' \"$ENV\"".to_string(), + ]; + let application_command = maybe_wrap_shell_lc_with_snapshot( + &application_command, + &shell, + Some(&snapshot), + &HashMap::new(), + &ordinary_env, + &RuntimePathPrepends::default(), + ); + assert_eq!( + run_nested(&application_command, &ordinary_env)?, + application_env_file.display().to_string() + ); + for flag in ["-c", "-lc"] { + let posix_application_command = maybe_wrap_shell_lc_with_snapshot( + &[ + "/bin/sh".to_string(), + flag.to_string(), + "printf '%s' \"$ENV\"".to_string(), + ], + &posix_shell, + Some(&snapshot), + &HashMap::new(), + &ordinary_env, + &RuntimePathPrepends::default(), + ); + assert_eq!( + run_nested(&posix_application_command, &ordinary_env)?, + application_env_file.display().to_string() + ); + } + + let changed_application_snapshot = dir.path().join("changed-application-snapshot.sh").abs(); + std::fs::write( + &changed_application_snapshot, + format!("export ENV='{}'\n", application_env_file.display()), + )?; + let bash_nested_command = wrap_bash_snapshot(&changed_application_snapshot, &env); + assert_eq!( + run_nested(&bash_nested_command, &env)?, + format!("{dummy}|{}", application_env_file.display()) + ); + let posix_nested_command = wrap_snapshot(&changed_application_snapshot, &env); + assert_eq!( + run_nested(&posix_nested_command, &env)?, + format!("{dummy}|{}", application_env_file.display()) + ); + let application_snapshot = dir.path().join("application-snapshot.sh"); + std::fs::write(&application_snapshot, "export ENV='production'\n")?; + let application_snapshot = application_snapshot.abs(); + let application_command = wrap_snapshot(&application_snapshot, &env); + assert_eq!( + run_nested(&application_command, &env)?, + format!("{dummy}|production") + ); + let mut snapshot_env = env.clone(); + snapshot_env.remove("ENV"); + let application_command = wrap_snapshot(&application_snapshot, &snapshot_env); + assert_eq!( + run_nested(&application_command, &snapshot_env)?, + format!("{dummy}|production") + ); + if std::path::Path::new("/bin/zsh").exists() { std::fs::write( dir.path().join(".zshenv"), @@ -909,7 +1265,7 @@ async fn snapshot_wrapper_replays_dummy_and_preserves_unbrokered_credentials() - std::os::unix::fs::symlink("/bin/zsh", &renamed_zsh)?; env.insert("ZDOTDIR".to_string(), dir.path().display().to_string()); let (zsh, _) = shell_with_snapshot(ShellType::Zsh, "/bin/zsh", snapshot.clone()); - prepare_brokered_shell_snapshot_env(&mut env, Some(&snapshot)); + prepare_brokered_shell_snapshot_env(&mut env, Some(&snapshot), &zsh); let command = vec![ renamed_zsh.display().to_string(), "-lc".to_string(), @@ -923,6 +1279,8 @@ async fn snapshot_wrapper_replays_dummy_and_preserves_unbrokered_credentials() - &env, &RuntimePathPrepends::default(), ); + assert!(!rewritten[2].contains(&dummy)); + assert!(!rewritten[2].contains(&dummy_alias)); assert_eq!(rewritten[1], "-lfc"); let output = Command::new(&rewritten[0]) .args(&rewritten[1..]) @@ -934,6 +1292,106 @@ async fn snapshot_wrapper_replays_dummy_and_preserves_unbrokered_credentials() - String::from_utf8_lossy(&output.stdout), format!("{dummy}\n{}", dir.path().display()) ); + + let system_startup_dir = dir.path().join("system-startup"); + std::fs::create_dir(&system_startup_dir)?; + let system_startup_zsh = system_startup_dir.join("zsh"); + std::fs::write( + &system_startup_zsh, + "#!/bin/sh\nexport OPENAI_API_KEY='sk-system-startup-secret'\nexport AUTH_HEADER='Bearer sk-system-startup-secret'\nexec /bin/zsh -ax \"$@\"\n", + )?; + let mut permissions = std::fs::metadata(&system_startup_zsh)?.permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&system_startup_zsh, permissions)?; + let (system_zsh, _) = shell_with_snapshot( + ShellType::Zsh, + system_startup_zsh.to_string_lossy().as_ref(), + snapshot.clone(), + ); + for (mut startup_env, expected_canonical, expected_alias) in [ + (alias_only_env, "unset", dummy_alias.as_str()), + (empty_canonical_env, "", dummy_alias.as_str()), + (absent_alias_env, dummy.as_str(), "unset"), + ] { + prepare_brokered_shell_snapshot_env(&mut startup_env, Some(&snapshot), &system_zsh); + let command = vec![ + system_startup_zsh.display().to_string(), + "-c".to_string(), + format!( + "set +x\nprintf '%s\\n%s\\n%s\\n%s\\n%s\\n' \"${{OPENAI_API_KEY-unset}}\" \"${{AUTH_HEADER-unset}}\" \"${{{SNAPSHOT_BROKERED_VALUE_ENV_PREFIX}AUTH_HEADER-unset}}\" \"${{{SNAPSHOT_BROKERED_VALUE_ENV_PREFIX}OPENAI_API_KEY-unset}}\" \"${{{SNAPSHOT_BROKERED_UNSET_ENV_PREFIX}OPENAI_API_KEY-unset}}\"\nenv -0" + ), + ]; + let system_rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &system_zsh, + Some(&snapshot), + &HashMap::new(), + &startup_env, + &RuntimePathPrepends::default(), + ); + let output = Command::new(&system_rewritten[0]) + .args(&system_rewritten[1..]) + .env_clear() + .envs(&startup_env) + .output()?; + assert!( + output.status.success(), + "system startup command failed: {output:?}" + ); + let stdout = String::from_utf8_lossy(&output.stdout); + let exported_env = stdout + .strip_prefix(&format!( + "{expected_canonical}\n{expected_alias}\nunset\nunset\nunset\n" + )) + .unwrap_or_else(|| panic!("unexpected system startup output: {stdout}")); + assert!(!stdout.contains("sk-system-startup-secret")); + assert!( + !exported_env + .split('\0') + .any(|entry| entry.starts_with("__CODEX_SNAPSHOT_PROXY_OVERRIDE_")) + ); + assert!(!String::from_utf8_lossy(&output.stderr).contains("sk-system-startup-secret")); + } + + for (bash_env, posix_env, zdotdir) in [ + ( + "$ZDOTDIR/startup.sh", + "$ZDOTDIR/startup.sh", + Some(dir.path()), + ), + ("${ZDOTDIR:-$HOME}/startup.sh", "$HOME/startup.sh", None), + ] { + let mut nested_zsh_env = env.clone(); + nested_zsh_env.remove(SNAPSHOT_ORIGINAL_ZDOTDIR_ENV_KEY); + nested_zsh_env.insert("HOME".to_string(), dir.path().display().to_string()); + nested_zsh_env.insert("BASH_ENV".to_string(), bash_env.to_string()); + nested_zsh_env.insert("ENV".to_string(), posix_env.to_string()); + if let Some(zdotdir) = zdotdir { + nested_zsh_env.insert("ZDOTDIR".to_string(), zdotdir.display().to_string()); + } else { + nested_zsh_env.remove("ZDOTDIR"); + } + prepare_brokered_shell_snapshot_env(&mut nested_zsh_env, Some(&snapshot), &zsh); + let command = wrap_brokered_snapshot(&zsh, &snapshot, &nested_zsh_env, &no_prepends); + assert_eq!( + run_nested(&command, &nested_zsh_env)?, + format!("{dummy}|unset") + ); + } + + env.remove("ZDOTDIR"); + env.remove(SNAPSHOT_ORIGINAL_ZDOTDIR_ENV_KEY); + prepare_brokered_shell_snapshot_env(&mut env, Some(&snapshot), &zsh); + let output = Command::new(&rewritten[0]) + .args(&rewritten[1..]) + .env_clear() + .envs(env) + .output()?; + assert!(output.status.success(), "zsh command failed: {output:?}"); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + format!("{dummy}\n{}", dir.path().display()) + ); } Ok(()) } @@ -1437,3 +1895,118 @@ fn maybe_wrap_shell_lc_with_snapshot_preserves_unset_override_variables() { assert!(output.status.success(), "command failed: {output:?}"); assert_eq!(String::from_utf8_lossy(&output.stdout), "unset"); } + +#[test] +fn brokered_credential_exports_stop_when_startup_makes_credential_readonly() { + let copy_key = format!("{SNAPSHOT_BROKERED_VALUE_ENV_PREFIX}OPENAI_API_KEY"); + let env = HashMap::from([(copy_key.clone(), "dummy-openai-key".to_string())]); + let restore = build_brokered_credential_exports(&env, /*remove_copies*/ true); + + let unset_output = Command::new("/bin/bash") + .args([ + "-c", + &format!( + "unset OPENAI_API_KEY\n{restore}\nprintf '%s|%s' \"${{OPENAI_API_KEY-unset}}\" \"${{{copy_key}-unset}}\"" + ), + ]) + .env(©_key, "dummy-openai-key") + .output() + .expect("run unset credential probe"); + assert!( + unset_output.status.success(), + "unset credential probe failed: {unset_output:?}" + ); + assert_eq!(String::from_utf8_lossy(&unset_output.stdout), "unset|unset"); + + let empty_output = Command::new("/bin/bash") + .args([ + "-c", + &format!( + "OPENAI_API_KEY=\n{restore}\nprintf '%s|%s' \"$OPENAI_API_KEY\" \"${{{copy_key}-unset}}\"" + ), + ]) + .env(©_key, "dummy-openai-key") + .output() + .expect("run empty credential probe"); + assert!( + empty_output.status.success(), + "empty credential probe failed: {empty_output:?}" + ); + assert_eq!(String::from_utf8_lossy(&empty_output.stdout), "|unset"); + + let unchanged_output = Command::new("/bin/bash") + .args([ + "-c", + &format!( + "readonly OPENAI_API_KEY=dummy-openai-key\n{restore}\nprintf '%s|%s' \"$OPENAI_API_KEY\" \"${{{copy_key}-unset}}\"" + ), + ]) + .env(©_key, "dummy-openai-key") + .output() + .expect("run unchanged readonly credential probe"); + assert!( + unchanged_output.status.success(), + "unchanged readonly credential probe failed: {unchanged_output:?}" + ); + assert_eq!( + String::from_utf8_lossy(&unchanged_output.stdout), + "dummy-openai-key|unset" + ); + + let xtrace_output = Command::new("/bin/bash") + .args([ + "-c", + &format!( + "OPENAI_API_KEY=real-openai-key\nset -x\n{restore}\nset +x\nprintf '%s|%s' \"$OPENAI_API_KEY\" \"${{{copy_key}-unset}}\"" + ), + ]) + .env(©_key, "dummy-openai-key") + .output() + .expect("run xtrace credential probe"); + assert!( + xtrace_output.status.success(), + "xtrace credential probe failed: {xtrace_output:?}" + ); + assert_eq!( + String::from_utf8_lossy(&xtrace_output.stdout), + "dummy-openai-key|unset" + ); + assert!(!String::from_utf8_lossy(&xtrace_output.stderr).contains("real-openai-key")); + + let unset_marker_key = format!("{SNAPSHOT_BROKERED_UNSET_ENV_PREFIX}OPENAI_API_KEY"); + let unset_env = HashMap::from([(unset_marker_key.clone(), "1".to_string())]); + let unset_restore = build_brokered_credential_exports(&unset_env, /*remove_copies*/ true); + let readonly_empty_output = Command::new("/bin/bash") + .args([ + "-c", + &format!( + "readonly OPENAI_API_KEY=\n{unset_restore}\nprintf '%s|%s' \"$OPENAI_API_KEY\" \"${{{unset_marker_key}-unset}}\"" + ), + ]) + .env(&unset_marker_key, "1") + .output() + .expect("run readonly empty credential probe"); + assert!( + readonly_empty_output.status.success(), + "readonly empty credential probe failed: {readonly_empty_output:?}" + ); + assert_eq!( + String::from_utf8_lossy(&readonly_empty_output.stdout), + "|unset" + ); + + let output = Command::new("/bin/bash") + .args([ + "-c", + &format!("readonly OPENAI_API_KEY=real-openai-key\n{restore}\nprintf should-not-run"), + ]) + .env(copy_key, "dummy-openai-key") + .output() + .expect("run readonly credential probe"); + + assert!(!output.status.success(), "command unexpectedly succeeded"); + assert!( + output.stdout.is_empty(), + "protected command unexpectedly ran" + ); +} diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 2f9d9327e6..d6a04c4e7f 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -43,6 +43,7 @@ use crate::unified_exec::UnifiedExecError; use crate::unified_exec::UnifiedExecProcess; use crate::unified_exec::UnifiedExecProcessManager; use codex_core_plugins::PluginMetricsSidecar; +use codex_network_proxy::CREDENTIAL_BROKER_ACTIVE_ENV_KEY; use codex_network_proxy::ManagedNetworkSandboxContext; use codex_network_proxy::NetworkProxy; use codex_protocol::error::CodexErr; @@ -405,7 +406,45 @@ impl<'a> ToolRuntime for UnifiedExecRunt } None => (env, None, None), }; - super::prepare_brokered_shell_snapshot_env(&mut env, shell_snapshot_location.as_ref()); + if let Some(snapshot) = shell_snapshot.as_ref() { + snapshot.restore_fail_open_aliases(&mut env); + } + if managed_network.is_some() + && !environment_is_remote + && shell.shell_type == ShellType::Sh + && env + .get(CREDENTIAL_BROKER_ACTIVE_ENV_KEY) + .is_some_and(|active| active == "1") + && let Some(startup_env) = shell_snapshot + .as_ref() + .and_then(|snapshot| snapshot.protected_startup_env()) + { + env.insert( + super::SNAPSHOT_ORIGINAL_POSIX_ENV_ENV_KEY.to_string(), + startup_env.to_string(), + ); + } + super::prepare_brokered_shell_snapshot_env( + &mut env, + shell_snapshot_location.as_ref(), + shell, + ); + if env + .get(CREDENTIAL_BROKER_ACTIVE_ENV_KEY) + .is_some_and(|active| active == "1") + && let Some(snapshot) = shell_snapshot.as_ref() + { + for key in snapshot.startup_credential_keys() { + if !super::is_valid_shell_variable_name(key) { + continue; + } + let (prefix, value) = match env.get(key) { + Some(value) => (super::SNAPSHOT_BROKERED_VALUE_ENV_PREFIX, value.clone()), + None => (super::SNAPSHOT_BROKERED_UNSET_ENV_PREFIX, "1".to_string()), + }; + env.insert(format!("{prefix}{key}"), value); + } + } let explicit_env_overrides = req.explicit_env_overrides.clone(); let metrics_sidecar = sidecar_for_command( ctx, @@ -485,7 +524,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt if !environment_is_remote && matches!(shell.shell_type, ShellType::PowerShell | ShellType::Cmd) && env - .get(codex_network_proxy::CREDENTIAL_BROKER_ACTIVE_ENV_KEY) + .get(CREDENTIAL_BROKER_ACTIVE_ENV_KEY) .is_some_and(|active| active == "1") && let Some(network) = managed_network { diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 9a6ffec4a2..efa1b98a88 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -2805,6 +2805,8 @@ async fn spawned_subagent_execpolicy_amendment_propagates_to_parent_session() -> #[test_case("direct", false, false; "explicit_zsh")] #[test_case("direct", true, false; "non_login_in_login_enabled_session")] #[test_case("direct", true, true; "login_startup")] +#[test_case("filtered_startup", false, false; "filtered_global_startup")] +#[test_case("startup_override", false, false; "global_startup_overrides")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn shell_startup_credentials_are_brokered( shell_mode: &'static str, @@ -2816,6 +2818,8 @@ async fn shell_startup_credentials_are_brokered( const GH_HOST: &str = "github.example.com"; const REAL_GITHUB_TOKEN: &str = "ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let global_startup = matches!(shell_mode, "filtered_startup" | "startup_override"); + let direct = shell_mode == "direct" || global_startup; let builder = if shell_mode == "zsh_fork" { let Some(runtime) = zsh_fork_runtime("zsh-fork credential broker environment test")? else { @@ -2844,6 +2848,7 @@ async fn shell_startup_credentials_are_brokered( format!( "export GH_HOST='{GH_HOST}'\n\ if [[ ! -o login ]]; then export GH_ENTERPRISE_TOKEN='{REAL_GITHUB_TOKEN}'; fi\n\ + export AUTH_HEADER=\"Bearer $GH_ENTERPRISE_TOKEN\"\n\ export APP_SETTING EXCLUDED_SETTING\n\ export BROKERED_STARTUP_CWD=\"$PWD\"\n\ if {untrusted_snapshot_condition}; then\n\ @@ -2860,13 +2865,32 @@ async fn shell_startup_credentials_are_brokered( fs::write( startup_dir.join(".zshrc"), format!( - "export LOGIN_SNAPSHOT_READY=ready\nexport GH_ENTERPRISE_TOKEN='{REAL_GITHUB_TOKEN}'\n" + "export LOGIN_SNAPSHOT_READY=ready\nexport GH_ENTERPRISE_TOKEN='{REAL_GITHUB_TOKEN}'\n\ + export AUTH_HEADER=\"Bearer $GH_ENTERPRISE_TOKEN\"\n" ), )?; fs::write( startup_dir.join(".zprofile"), "export LOGIN_SHELL_READY=ready\n", )?; + let startup_shell = if global_startup { + let Some(zsh) = codex_core::shell::get_shell(codex_core::shell::ShellType::Zsh) else { + return Ok(()); + }; + let zsh_path = zsh.derive_exec_args("", /*use_login_shell*/ false)[0].clone(); + let path = startup_dir.join("zsh"); + fs::write( + &path, + format!( + "#!/bin/sh\nexport GH_ENTERPRISE_TOKEN='{REAL_GITHUB_TOKEN}'\nexport AUTH_HEADER=\"Bearer $GH_ENTERPRISE_TOKEN\"\nexec {} \"$@\"\n", + shlex::try_quote(&zsh_path)? + ), + )?; + fs::set_permissions(&path, fs::Permissions::from_mode(/*mode*/ 0o700))?; + Some(path) + } else { + None + }; fs::write( home.path().join("config.toml"), format!( @@ -2883,7 +2907,7 @@ ZDOTDIR = "{}" let mut builder = builder .with_home(home) .with_cloud_config_bundle(managed_network_requirements_loader()); - if shell_mode == "direct" && !allow_login_shell { + if direct && !allow_login_shell { let Some(bash) = codex_core::shell::get_shell(codex_core::shell::ShellType::Bash) else { return Ok(()); }; @@ -2894,11 +2918,21 @@ ZDOTDIR = "{}" config.permissions.shell_environment_policy.exclude.push( EnvironmentVariablePattern::new_case_insensitive("EXCLUDED_SETTING"), ); + if shell_mode == "filtered_startup" { + config.permissions.shell_environment_policy.exclude.push( + EnvironmentVariablePattern::new_case_insensitive("GH_ENTERPRISE_TOKEN"), + ); + } else if shell_mode == "startup_override" { + config.permissions.shell_environment_policy.r#set.extend([ + ("GH_ENTERPRISE_TOKEN".to_string(), String::new()), + ("AUTH_HEADER".to_string(), "enterprise-header".to_string()), + ]); + } config .features .enable(Feature::ShellSnapshot) .expect("test config should allow ShellSnapshot override"); - if shell_mode == "direct" { + if direct { config .features .disable(Feature::ShellZshFork) @@ -2916,19 +2950,22 @@ ZDOTDIR = "{}" workdir }; let call_id = "zsh-fork-brokered-github-credential"; - let command = r#"printf '%s\n%s\n%s\n%s\n%s\n%s\n' "$GH_HOST" "$GH_ENTERPRISE_TOKEN" "$CODEX_NETWORK_PROXY_CREDENTIAL_BROKER_ACTIVE" "$PWD" "$BROKERED_STARTUP_CWD" "${LOGIN_SNAPSHOT_READY-unset}""#; + let command = r#"printf '%s\n%s\n%s\n%s\n%s\n%s\n%s\n' "$GH_HOST" "$GH_ENTERPRISE_TOKEN" "$CODEX_NETWORK_PROXY_CREDENTIAL_BROKER_ACTIVE" "$PWD" "$BROKERED_STARTUP_CWD" "${LOGIN_SNAPSHOT_READY-unset}" "$AUTH_HEADER""#; let command = format!( "APP_SETTING=production; EXCLUDED_SETTING=denied; [ \"$(/usr/bin/printenv APP_SETTING)\" = production ] || exit 1; if /usr/bin/printenv EXCLUDED_SETTING >/dev/null; then exit 1; fi; {command}" ); let snapshot_dir_arg = shlex::try_join([snapshot_dir.to_string_lossy().as_ref()])?; let command = format!("{command}; /bin/cat {snapshot_dir_arg}/*.sh > captured-snapshot"); - let arguments = if shell_mode == "direct" { + let arguments = if direct { let Some(zsh) = codex_core::shell::get_shell(codex_core::shell::ShellType::Zsh) else { return Ok(()); }; json!({ "cmd": command, - "shell": zsh.derive_exec_args("", /*use_login_shell*/ false)[0], + "shell": startup_shell.map_or_else( + || zsh.derive_exec_args("", /*use_login_shell*/ false)[0].clone(), + |path| path.display().to_string(), + ), "workdir": workdir, "login": login, "yield_time_ms": 10_000, @@ -2978,11 +3015,24 @@ ZDOTDIR = "{}" assert_eq!(result.exit_code, Some(0), "command failed: {result:?}"); assert!(!result.stdout.contains(REAL_GITHUB_TOKEN)); let values = result.stdout.lines().collect::>(); - assert_eq!(values.len(), 6, "unexpected command output: {result:?}"); + assert_eq!(values.len(), 7, "unexpected command output: {result:?}"); assert_eq!(values[0], GH_HOST); assert_ne!(values[1], REAL_GITHUB_TOKEN); - assert!(values[1].starts_with("ghp_")); - assert_eq!(values[1].len(), REAL_GITHUB_TOKEN.len()); + if global_startup { + assert_eq!(values[1], ""); + assert_eq!( + values[6], + if shell_mode == "startup_override" { + "enterprise-header" + } else { + "" + } + ); + } else { + assert!(values[1].starts_with("ghp_")); + assert_eq!(values[1].len(), REAL_GITHUB_TOKEN.len()); + assert_eq!(values[6], format!("Bearer {}", values[1])); + } assert_eq!(values[2], "1"); assert_eq!(PathBuf::from(values[3]), fs::canonicalize(&workdir)?); assert_eq!(PathBuf::from(values[4]), fs::canonicalize(&workdir)?); @@ -3253,6 +3303,117 @@ GH_TOKEN = "{REAL_GITHUB_TOKEN}" Ok(()) } +#[cfg(unix)] +#[test_case(true; "credential_startup")] +#[test_case(false; "ordinary_application_env")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn brokered_posix_startup_preserves_application_env(credential_startup: bool) -> Result<()> { + skip_if_no_network!(Ok(())); + const REAL_TOKEN: &str = "ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let Some(shell) = codex_core::shell::get_shell(codex_core::shell::ShellType::Sh) else { + return Ok(()); + }; + let home = Arc::new(TempDir::new()?); + let startup = home.path().join("startup.sh"); + let credential_export = format!("export GH_TOKEN='{REAL_TOKEN}'\n"); + fs::write( + &startup, + format!( + "export CORP_REGION=west\n{}", + if credential_startup { + credential_export.as_str() + } else { + "" + } + ), + )?; + fs::write( + home.path().join("config.toml"), + format!( + r#"default_permissions = "brokered" +features = {{ network_proxy = {{ enabled = true, credential_broker = true }} }} +permissions = {{ brokered = {{ extends = ":workspace", network = {{ enabled = true, allow_local_binding = true }} }} }} +[shell_environment_policy.set] +ENV = "{}" +"#, + startup.display() + ), + )?; + let shell_path = shell.derive_exec_args("", /*use_login_shell*/ false)[0].clone(); + let server = start_mock_server().await; + let test = test_codex() + .with_home(home) + .with_user_shell(shell) + .with_cloud_config_bundle(managed_network_requirements_loader()) + .with_config(move |config| { + config.permissions.approval_policy = Constrained::allow_any(AskForApproval::Never); + config.permissions.allow_login_shell = true; + config.features.enable(Feature::ShellSnapshot).unwrap(); + config.features.disable(Feature::ShellZshFork).unwrap(); + if !credential_startup { + config + .permissions + .shell_environment_policy + .r#set + .insert("GH_TOKEN".to_string(), REAL_TOKEN.to_string()); + } + }) + .build(&server) + .await?; + let script = r#"printf '%s\n%s\n%s\n%s\n' "$GH_TOKEN" "$CODEX_NETWORK_PROXY_CREDENTIAL_BROKER_ACTIVE" "${ENV-unset}" "$CORP_REGION""#; + let command = format!( + "{} 2>/dev/null", + shlex::try_join([shell_path.as_str(), "-i", "-c", script])? + ); + let call_id = "brokered-posix-startup"; + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("posix-startup-1"), + shell_event( + call_id, + &command, + /*timeout_ms*/ 5_000, + SandboxPermissions::UseDefault, + )?, + ev_completed("posix-startup-1"), + ]), + sse(vec![ + ev_assistant_message("posix-startup-done", "done"), + ev_completed("posix-startup-2"), + ]), + ], + ) + .await; + submit_turn_preserving_active_permission_profile( + &test, + "show the POSIX startup environment", + AskForApproval::Never, + ) + .await?; + wait_for_completion_without_approval(&test).await; + let result = parse_result(&responses.requests()[1].function_call_output(call_id)); + assert_eq!(result.exit_code, Some(0), "command failed: {result:?}"); + assert!(!result.stdout.contains(REAL_TOKEN)); + let values = result.stdout.lines().collect::>(); + assert_eq!(values.len(), 4, "unexpected command output: {result:?}"); + assert!(values[0].starts_with("ghp_")); + assert_eq!( + &values[1..], + &[ + "1", + if credential_startup { + "unset" + } else { + startup.to_str().unwrap() + }, + "west" + ] + ); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[cfg(unix)] async fn env_zsh_script_spawned_by_python_can_request_escalation_under_zsh_fork() -> Result<()> { diff --git a/codex-rs/network-proxy/src/credential_broker.rs b/codex-rs/network-proxy/src/credential_broker.rs index 10722da569..f2898014b1 100644 --- a/codex-rs/network-proxy/src/credential_broker.rs +++ b/codex-rs/network-proxy/src/credential_broker.rs @@ -11,6 +11,7 @@ use std::sync::RwLock; pub const CREDENTIAL_BROKER_ACTIVE_ENV_KEY: &str = "CODEX_NETWORK_PROXY_CREDENTIAL_BROKER_ACTIVE"; pub(crate) const BROKERED_CREDENTIALS_ENV_KEY: &str = "CODEX_NETWORK_PROXY_BROKERED_CREDENTIALS"; const MIN_EMBEDDED_CREDENTIAL_LENGTH: usize = 16; +const BROKERED_CREDENTIAL_ALIAS_MARKER_PREFIX: &str = "@alias:"; #[derive(Clone)] pub(crate) struct CredentialBroker { @@ -596,19 +597,37 @@ fn update_brokered_credentials_marker( state: &CredentialBrokerState, env: &mut HashMap, ) { - let brokered = providers::credential_env_keys() - .filter_map(|key| { - let (actual_key, value) = env_entry(env, key)?; - state - .credentials - .iter() - .any(|credential| { - env_key_matches(actual_key, &credential.env_var) - && value == credential.dummy_value - }) - .then_some((actual_key, value)) + let mut brokered = state + .credentials + .iter() + .filter(|credential| { + env.iter().any(|(key, value)| { + !env_key_matches(key, CREDENTIAL_BROKER_ACTIVE_ENV_KEY) + && !env_key_matches(key, BROKERED_CREDENTIALS_ENV_KEY) + && (value == &credential.dummy_value + || credential.dummy_value.len() >= MIN_EMBEDDED_CREDENTIAL_LENGTH + && value.contains(&credential.dummy_value)) + }) }) + .map(|credential| (credential.env_var.clone(), credential.dummy_value.clone())) .collect::>(); + let brokered_dummy_values = brokered + .iter() + .map(|(_, dummy_value)| dummy_value.clone()) + .collect::>(); + brokered.extend(state.credential_aliases.iter().filter_map(|alias| { + let dummy_value = brokered_dummy_values.iter().find(|&dummy_value| { + alias.dummy_value == *dummy_value + || dummy_value.len() >= MIN_EMBEDDED_CREDENTIAL_LENGTH + && alias.dummy_value.contains(dummy_value) + })?; + Some(( + format!("{BROKERED_CREDENTIAL_ALIAS_MARKER_PREFIX}{}", alias.env_var), + dummy_value.clone(), + )) + })); + brokered.sort_unstable(); + brokered.dedup(); match serde_json::to_string(&brokered) { Ok(marker) => { set_env_value(env, BROKERED_CREDENTIALS_ENV_KEY, marker); @@ -660,6 +679,77 @@ pub fn brokered_credential_dummy_env_keys(env: &HashMap) -> Vec< keys } +/// Returns canonical credential and known alias keys recorded for an active brokered child, +/// including keys that are currently absent. +pub fn brokered_credential_marker_env_keys(env: &HashMap) -> Vec { + if env_value(env, CREDENTIAL_BROKER_ACTIVE_ENV_KEY) != Some("1") { + return Vec::new(); + } + let entries = env_value(env, BROKERED_CREDENTIALS_ENV_KEY) + .and_then(|marker| serde_json::from_str::>(marker).ok()) + .unwrap_or_default(); + let dummy_values = entries + .iter() + .filter(|(key, _)| { + providers::credential_env_keys().any(|candidate| env_key_matches(key, candidate)) + }) + .map(|(_, dummy_value)| dummy_value.clone()) + .collect::>(); + let mut keys = entries + .into_iter() + .filter_map(|(key, value)| { + if providers::credential_env_keys().any(|candidate| env_key_matches(&key, candidate)) { + return Some(key); + } + let alias_key = key.strip_prefix(BROKERED_CREDENTIAL_ALIAS_MARKER_PREFIX)?; + let known_alias = dummy_values.iter().any(|dummy_value| { + value == *dummy_value + || dummy_value.len() >= MIN_EMBEDDED_CREDENTIAL_LENGTH + && value.contains(dummy_value) + }); + known_alias.then(|| alias_key.to_string()) + }) + .collect::>(); + keys.sort_unstable(); + keys.dedup(); + keys +} + +/// Returns environment keys whose current values are brokered dummies or aliases containing them, +/// including aliases retained after their canonical source variable was removed. +pub fn brokered_credential_value_env_keys(env: &HashMap) -> Vec { + if env_value(env, CREDENTIAL_BROKER_ACTIVE_ENV_KEY) != Some("1") { + return Vec::new(); + } + let dummy_values = env_value(env, BROKERED_CREDENTIALS_ENV_KEY) + .and_then(|marker| serde_json::from_str::>(marker).ok()) + .unwrap_or_default() + .into_iter() + .filter_map(|(key, dummy_value)| { + providers::credential_env_keys() + .any(|candidate| env_key_matches(&key, candidate)) + .then_some(dummy_value) + }) + .collect::>(); + let mut keys = env + .iter() + .filter(|(key, _)| { + !env_key_matches(key, CREDENTIAL_BROKER_ACTIVE_ENV_KEY) + && !env_key_matches(key, BROKERED_CREDENTIALS_ENV_KEY) + }) + .filter(|(_, value)| { + dummy_values.iter().any(|dummy| { + value.as_str() == dummy.as_str() + || dummy.len() >= MIN_EMBEDDED_CREDENTIAL_LENGTH + && value.contains(dummy.as_str()) + }) + }) + .map(|(key, _)| key.clone()) + .collect::>(); + keys.sort_unstable(); + keys +} + /// Returns environment keys used to bind currently brokered credentials to hosts. pub fn brokered_credential_binding_env_keys( env: &HashMap, diff --git a/codex-rs/network-proxy/src/credential_broker_tests.rs b/codex-rs/network-proxy/src/credential_broker_tests.rs index d9155987a3..34e4244a81 100644 --- a/codex-rs/network-proxy/src/credential_broker_tests.rs +++ b/codex-rs/network-proxy/src/credential_broker_tests.rs @@ -765,6 +765,54 @@ fn brokered_credential_env_keys_only_include_registered_credentials() { ); } +#[test] +fn brokered_credential_value_env_keys_include_dummy_aliases() { + let broker = CredentialBroker::new(/*enabled*/ true); + let real = "sk-proj-abcdefghijklmnopqrstuvwxyz"; + let alias = format!("Bearer {real}"); + let mut env = env_map([("OPENAI_API_KEY", real), ("AUTH_HEADER", alias.as_str())]); + + broker.virtualize_child_env(&mut env); + + let marker: Vec<(String, String)> = + serde_json::from_str(&env[BROKERED_CREDENTIALS_ENV_KEY]).unwrap(); + assert_eq!( + marker, + vec![ + ( + "@alias:AUTH_HEADER".to_string(), + env["OPENAI_API_KEY"].clone() + ), + ("OPENAI_API_KEY".to_string(), env["OPENAI_API_KEY"].clone()), + ] + ); + + assert_eq!( + brokered_credential_value_env_keys(&env), + vec!["AUTH_HEADER".to_string(), "OPENAI_API_KEY".to_string()] + ); + + let mut absent_alias_env = env.clone(); + absent_alias_env.remove("AUTH_HEADER"); + broker.virtualize_child_env(&mut absent_alias_env); + assert_eq!( + brokered_credential_marker_env_keys(&absent_alias_env), + vec!["AUTH_HEADER".to_string(), "OPENAI_API_KEY".to_string()] + ); + + env.remove("OPENAI_API_KEY"); + broker.virtualize_child_env(&mut env); + assert!(brokered_credential_dummy_env_keys(&env).is_empty()); + assert_eq!( + brokered_credential_marker_env_keys(&env), + vec!["AUTH_HEADER".to_string(), "OPENAI_API_KEY".to_string()] + ); + assert_eq!( + brokered_credential_value_env_keys(&env), + vec!["AUTH_HEADER".to_string()] + ); +} + #[test] fn virtualize_child_env_uses_fresh_dummy_capabilities() { let mut first_env = env_map([("OPENAI_API_KEY", "sk-proj-abcdefghijklmnopqrstuvwxyz")]); diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index f71a334aed..52543f5842 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -47,6 +47,8 @@ pub use credential_broker::CREDENTIAL_BROKER_ACTIVE_ENV_KEY; pub use credential_broker::brokered_credential_binding_env_keys; pub use credential_broker::brokered_credential_dummy_env_keys; pub use credential_broker::brokered_credential_env_keys; +pub use credential_broker::brokered_credential_marker_env_keys; +pub use credential_broker::brokered_credential_value_env_keys; pub use credential_broker::credential_broker_provider_context_env_keys; pub use credential_broker::credential_broker_provider_sources_allowed; pub use credential_broker::is_credential_broker_provider_env_key; diff --git a/codex-rs/shell-command/src/shell_snapshot.rs b/codex-rs/shell-command/src/shell_snapshot.rs index 228029cbd1..5ebdd569af 100644 --- a/codex-rs/shell-command/src/shell_snapshot.rs +++ b/codex-rs/shell-command/src/shell_snapshot.rs @@ -6,6 +6,8 @@ mod capture; mod credentials; #[path = "shell_snapshot_exports.rs"] mod exports; +#[path = "shell_snapshot_literals.rs"] +mod literals; #[path = "shell_snapshot_render.rs"] mod render; @@ -17,6 +19,8 @@ pub use credentials::PreparedSnapshot; pub use credentials::SnapshotCredentialEnvironment; pub use credentials::prepare_snapshot_credentials; +use capture::BASH_SH_SNAPSHOT_HEADER; + #[cfg(all(test, unix))] #[path = "shell_snapshot_tests.rs"] mod tests; diff --git a/codex-rs/shell-command/src/shell_snapshot_capture.rs b/codex-rs/shell-command/src/shell_snapshot_capture.rs index ecf8b4eef7..263d196970 100644 --- a/codex-rs/shell-command/src/shell_snapshot_capture.rs +++ b/codex-rs/shell-command/src/shell_snapshot_capture.rs @@ -1,8 +1,9 @@ //! Capture native shell state and decode complete export records without applying credential policy. -//! Retain native declaration flags and reject incomplete records. +//! Retain native declaration flags and tied-array boundaries; reject incomplete records. use super::SnapshotStartup; use super::exports; +use super::literals; use super::posix_env_path_expansion_function; use crate::shell_detect::ShellType; use std::borrow::Cow; @@ -156,6 +157,8 @@ SNAPSHOT_ENVIRONMENT format!("{startup}{script}") } +pub(super) const BASH_SH_SNAPSHOT_HEADER: &str = "# Snapshot file\n# Bash-backed sh\n"; + fn sh_snapshot_script(shell_startup: SnapshotStartup) -> String { let startup = match shell_startup { SnapshotStartup::Interactive => [ @@ -229,6 +232,7 @@ SNAPSHOT_ENVIRONMENT pub struct CapturedSnapshot<'a> { /// Resolved POSIX startup file and its environment immediately before sourcing it. pub startup_environment: Option>, + pub(super) shell_type: ShellType, pub(super) state: &'a str, pub(super) aliases: &'a str, pub(super) exports: Vec>, @@ -238,13 +242,26 @@ pub struct CapturedSnapshot<'a> { /// Optional pre-startup state captured alongside the final snapshot records. pub struct CapturedStartupEnvironment<'a> { - pub path: &'a str, + pub path: Cow<'a, str>, pub environment: &'a [u8], } pub(super) struct CapturedExport<'a> { pub source: Cow<'a, str>, pub key: &'a str, + pub value: ExportValue, +} + +pub(super) enum ExportValue { + Plain, + TiedArray(CapturedArray), + UnparsedArray, +} + +pub(super) struct CapturedArray { + pub span: std::ops::Range, + pub values: Vec>, + pub separator: Vec, } impl<'a> CapturedSnapshot<'a> { @@ -262,7 +279,7 @@ impl<'a> CapturedSnapshot<'a> { .position(|part| part == startup_marker) { remaining = &remaining[start + startup_marker.len()..]; - let path = std::str::from_utf8(take_record(&mut remaining)?).ok()?; + let path = String::from_utf8_lossy(take_record(&mut remaining)?); let environment = remaining; while !take_record(&mut remaining)?.is_empty() {} let environment = &environment[..environment.len() - remaining.len() - 1]; @@ -310,11 +327,13 @@ impl<'a> CapturedSnapshot<'a> { shlex::try_quote(&value).ok()? )), key, + value: ExportValue::Plain, }); } } return Some(Self { startup_environment, + shell_type, state, aliases, exports: records, @@ -322,7 +341,75 @@ impl<'a> CapturedSnapshot<'a> { }); } let source = String::from_utf8_lossy(take_record(&mut remaining)?); - records.push(CapturedExport { source, key }); + // Only tied arrays need syntax decoding; scalar values remain native declarations. + let tied = shell_type == ShellType::Zsh + && source + .split_whitespace() + .skip(/*n*/ 1) + .take_while(|word| word.starts_with('-')) + .any(|flags| flags.contains('T')); + let tree = tied + .then(|| crate::bash::try_parse_shell(&source)) + .flatten(); + let array = tree.as_ref().and_then(|tree| { + if tree.root_node().has_error() { + return None; + } + let mut cursor = tree.root_node().walk(); + tree.root_node() + .named_child(/*i*/ 0)? + .named_children(&mut cursor) + .find_map(|child| { + child + .child_by_field_name("value") + .filter(|value| value.kind() == "array") + }) + }); + let value = if let Some(array) = array { + let mut cursor = array.walk(); + let values = array + .named_children(&mut cursor) + .map(|node| { + literals::literal_word_bytes( + node, + &source, + shell_type, + literals::Quoting::Posix, + ) + }) + .collect::>>(); + let separator = array.parent().and_then(|node| node.next_named_sibling()); + let separator = if let Some(separator) = separator + && separator.kind() == "variable_name" + { + separator + .utf8_text(source.as_bytes()) + .ok() + .map(|value| value.as_bytes().to_vec()) + } else if let Some(separator) = separator { + literals::literal_word_bytes( + separator, + &source, + shell_type, + literals::Quoting::Posix, + ) + } else { + Some(vec![b':']) + }; + match (values, separator) { + (Some(values), Some(separator)) => ExportValue::TiedArray(CapturedArray { + span: array.byte_range(), + values, + separator, + }), + _ => ExportValue::UnparsedArray, + } + } else if tied { + ExportValue::UnparsedArray + } else { + ExportValue::Plain + }; + records.push(CapturedExport { source, key, value }); } } } diff --git a/codex-rs/shell-command/src/shell_snapshot_credentials.rs b/codex-rs/shell-command/src/shell_snapshot_credentials.rs index d2cf0054f4..66a96f7b65 100644 --- a/codex-rs/shell-command/src/shell_snapshot_credentials.rs +++ b/codex-rs/shell-command/src/shell_snapshot_credentials.rs @@ -1,7 +1,9 @@ //! Apply credential and environment policy to captured exports, producing typed render values. -//! Native declarations are rendered only after applying the supplied credential policy. +//! The shared literal decoder still validates alias and function bodies without evaluating them. use super::capture::CapturedSnapshot; +use super::capture::ExportValue; +use super::literals; use super::render; use super::render::Export; use super::render::Value; @@ -32,7 +34,7 @@ pub struct PreparedSnapshot { /// Prepare a replay script from captured state without changing the captured data. /// /// Credential policy is applied before rendering. A rejected capture produces no script, -/// when the assembled source still contains a recognized credential. +/// including when executable source reconstructs a credential through shell quoting. pub fn prepare_snapshot_credentials( captured: &CapturedSnapshot<'_>, environment: SnapshotCredentialEnvironment<'_>, @@ -136,7 +138,7 @@ pub fn prepare_snapshot_credentials( let mut alias_values = HashMap::new(); let mut rejected_alias_keys = Vec::new(); let mut invalid_export = false; - let mut exports = captured + let exports = captured .exports .iter() .filter_map(|export| { @@ -148,14 +150,46 @@ pub fn prepare_snapshot_credentials( || brokered_keys .iter() .any(|credential_key| credential_key == key) - || brokered_alias_keys - .iter() - .any(|credential_key| credential_key == key) - || is_disallowed_credential_alias(key, &mut virtualize_text) { return None; } + if configured.contains_key(key) + && let ExportValue::TiedArray(array) = &export.value + { + let value = allowed.get(key)?; + let mut virtualized = value.clone(); + if !virtualize_text(&mut virtualized) || virtualized != *value { + invalid_export = true; + return Some(Export::Captured(line)); + } + if credential_aliases.remove(key).is_some() { + alias_values.insert(key.to_string(), value.clone()); + } + // An explicit scalar override replaces captured elements, not the array binding. + return Some(Export::ArrayBinding { + key, + declaration: line[..array.span.start].strip_suffix('=')?, + suffix: &line[array.span.end..], + }); + } + + if is_disallowed_credential_alias(key, &mut virtualize_text) { + return None; + } + + if brokered_alias_keys.iter().any(|alias_key| alias_key == key) { + // Source-less aliases keep their live scalar value, including explicit unsets. + return match &export.value { + ExportValue::TiedArray(array) => Some(Export::ArrayBinding { + key, + declaration: line[..array.span.start].strip_suffix('=')?, + suffix: &line[array.span.end..], + }), + ExportValue::Plain | ExportValue::UnparsedArray => None, + }; + } + if let Some(credential_keys) = credential_aliases.remove(key) { if !credential_alias_is_allowed(&credential_keys) { rejected_alias_keys.push(key.to_string()); @@ -167,12 +201,74 @@ pub fn prepare_snapshot_credentials( let (assignment, value) = credential_alias_assignment(allowed.get(key)?, &credential_keys)?; alias_values.insert(key.to_string(), value); - if line - .split_whitespace() - .skip(/*n*/ 1) - .take_while(|word| word.starts_with('-')) - .any(|flags| flags.contains('T')) - { + if let ExportValue::TiedArray(array) = &export.value { + let scalar = array.values.join(array.separator.as_slice()); + let crosses_element = credential_keys + .iter() + .flat_map(|key| { + [ + real_credential_value(key), + original.get(*key), + discovered.get(*key), + ] + }) + .flatten() + .filter(|credential| !credential.is_empty()) + .any(|credential| { + scalar + .windows(credential.len()) + .enumerate() + .any(|(start, bytes)| { + if bytes != credential.as_bytes() { + return false; + } + let mut offset = 0; + !array.values.iter().any(|value| { + let contains = offset <= start + && start + credential.len() <= offset + value.len(); + offset += value.len() + array.separator.len(); + contains + }) + }) + }); + if crosses_element { + invalid_export = true; + return Some(Export::Captured(line)); + } + let Some((elements, normalized)) = array + .values + .iter() + .map(|bytes| { + let mut text = String::from_utf8(bytes.clone()).ok()?; + if !virtualize_text(&mut text) { + return None; + } + credential_alias_assignment(&text, &credential_keys) + }) + .collect::>>() + .map(|values| values.into_iter().unzip::<_, _, Vec<_>, Vec<_>>()) + else { + invalid_export = true; + return Some(Export::Captured(line)); + }; + // Never replay a known credential that crosses array-element boundaries. + if normalized + .iter() + .map(String::as_bytes) + .collect::>() + .join(array.separator.as_slice()) + != allowed.get(key)?.as_bytes() + { + invalid_export = true; + return Some(Export::Captured(line)); + } + return Some(Export::Array { + prefix: &line[..array.span.start], + elements, + suffix: &line[array.span.end..], + }); + } + if matches!(export.value, ExportValue::UnparsedArray) { invalid_export = true; return Some(Export::Captured(line)); } @@ -185,6 +281,7 @@ pub fn prepare_snapshot_credentials( Some(Export::Captured(line)) }) .collect::>(); + let snapshot = render::render(captured.state, captured.aliases, &exports)?; if invalid_export { return None; } @@ -192,38 +289,31 @@ pub fn prepare_snapshot_credentials( for (key, credential_keys) in credential_aliases { if !is_disallowed_credential_alias(key, &mut virtualize_text) && credential_alias_is_allowed(&credential_keys) - && let Some((assignment, value)) = allowed + && let Some((_, value)) = allowed .get(key) .and_then(|value| credential_alias_assignment(value, &credential_keys)) { - exports.push(Export::Alias { - key, - value: assignment, - }); + // Native capture can deliberately omit readonly exports; retain only their metadata. alias_values.insert(key.to_string(), value); } else { rejected_alias_keys.push(key.to_string()); } } - let mut snapshot = render::render(captured.state, captured.aliases, &exports)?; - let mut credential_values = original - .values() - .chain(restored.values()) - .chain(configured.values()) - .collect::>(); - credential_values.sort_unstable_by_key(|value| (std::cmp::Reverse(value.len()), *value)); - credential_values.dedup(); - for value in credential_values { - let mut replacement = value.clone(); - if virtualize_text(&mut replacement) && replacement != *value { - snapshot = snapshot.replace(value, &replacement); - } + let mut virtualized = snapshot.clone(); + if !virtualize_text(&mut virtualized) || virtualized != snapshot { + return None; } - Some(PreparedSnapshot { - script: snapshot, - aliases: alias_values, - rejected_alias_keys, - }) + literals::literal_words(&snapshot, captured.shell_type)? + .into_iter() + .all(|word| { + let mut virtualized = word.clone(); + virtualize_text(&mut virtualized) && virtualized == word + }) + .then_some(PreparedSnapshot { + script: snapshot, + aliases: alias_values, + rejected_alias_keys, + }) } diff --git a/codex-rs/shell-command/src/shell_snapshot_literals.rs b/codex-rs/shell-command/src/shell_snapshot_literals.rs new file mode 100644 index 0000000000..6985a34d3b --- /dev/null +++ b/codex-rs/shell-command/src/shell_snapshot_literals.rs @@ -0,0 +1,715 @@ +use crate::bash::try_parse_shell; +use crate::shell_detect::ShellType; +use std::collections::HashSet; +use tree_sitter::Node; + +#[derive(Clone, Copy, Eq, Hash, PartialEq)] +pub(super) enum Quoting { + Posix, + LegacyBash, + ZshRcQuotes, +} + +impl Quoting { + fn deferred_modes(shell_type: ShellType) -> &'static [Self] { + if matches!(shell_type, ShellType::Bash | ShellType::Sh) { + &[Self::Posix, Self::LegacyBash] + } else { + &[Self::Posix, Self::ZshRcQuotes] + } + } +} + +#[derive(Clone, Copy)] +enum Parsing { + Snapshot, + Deferred(Quoting), +} + +pub(super) fn literal_words(script: &str, shell_type: ShellType) -> Option> { + // Bash invoked as sh still parses its array and alias syntax; other sh dialects do not. + let shell_type = + if shell_type == ShellType::Sh && script.starts_with(super::BASH_SH_SNAPSHOT_HEADER) { + ShellType::Bash + } else { + shell_type + }; + let mut checked_aliases = HashSet::new(); + let mut words = + literal_words_with_quoting(script, shell_type, Parsing::Snapshot, &mut checked_aliases)?; + if matches!(shell_type, ShellType::Bash | ShellType::Sh) { + words.extend(literal_words_with_quoting( + script, + shell_type, + Parsing::Deferred(Quoting::LegacyBash), + &mut checked_aliases, + )?); + } + Some(words) +} + +fn literal_words_with_quoting( + script: &str, + shell_type: ShellType, + parsing: Parsing, + checked_aliases: &mut HashSet<(String, Quoting)>, +) -> Option> { + let tree = try_parse_shell(script)?; + let mut cursor = tree.root_node().walk(); + let mut quoting = match parsing { + Parsing::Snapshot => Quoting::Posix, + Parsing::Deferred(quoting) => quoting, + }; + let mut pending = Vec::new(); + // Zsh snapshots emit function definitions before restoring options and aliases. + for node in tree.root_node().named_children(&mut cursor) { + if matches!(parsing, Parsing::Snapshot) && node.kind() == "command" { + match node.utf8_text(script.as_bytes()).ok()? { + "setopt rcquotes" => quoting = Quoting::ZshRcQuotes, + "unsetopt rcquotes" => quoting = Quoting::Posix, + _ => {} + } + } + pending.push((node, quoting)); + } + let mut words = Vec::new(); + let mut checked_nodes = HashSet::new(); + while let Some((node, quoting)) = pending.pop() { + if !checked_nodes.insert((node.id(), quoting)) { + continue; + } + if matches!(node.kind(), "command_substitution" | "process_substitution") { + if let Some(body) = node + .utf8_text(script.as_bytes()) + .ok()? + .strip_prefix('`') + .and_then(|body| body.strip_suffix('`')) + { + // Backticks remove one escape layer before their command body is parsed. + let mut context = node.parent(); + if shell_type == ShellType::Zsh { + while context.is_some_and(|parent| { + matches!(parent.kind(), "expansion" | "concatenation") + }) { + context = context.and_then(|parent| parent.parent()); + } + } + let in_double_quotes = context.is_some_and(|parent| parent.kind() == "string"); + let mut chars = body.chars().peekable(); + let mut body = String::new(); + while let Some(character) = chars.next() { + if character == '\\' { + match chars.peek() { + Some('\n') => { + chars.next(); + continue; + } + Some('$' | '`' | '\\') => { + body.push(chars.next()?); + continue; + } + Some('"') if in_double_quotes => { + body.push(chars.next()?); + continue; + } + _ => {} + } + } + body.push(character); + } + for "ing in Quoting::deferred_modes(shell_type) { + if checked_aliases.insert((body.clone(), quoting)) { + words.extend(literal_words_with_quoting( + &body, + shell_type, + Parsing::Deferred(quoting), + checked_aliases, + )?); + } + } + continue; + } + // Deferred bodies may be parsed after the snapshot restores RC_QUOTES. + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + pending.extend( + Quoting::deferred_modes(shell_type) + .iter() + .map(|"ing| (child, quoting)), + ); + } + continue; + } + if node.kind() == "heredoc_body" { + let parent = node.parent()?; + let mut cursor = parent.walk(); + let delimiter = parent + .named_children(&mut cursor) + .find(|child| child.kind() == "heredoc_start")? + .utf8_text(script.as_bytes()) + .ok()?; + let quoted = delimiter.contains(['\'', '"', '\\']); + let strip_tabs = parent + .children(&mut cursor) + .any(|child| child.kind() == "<<-"); + let mut start = node.start_byte(); + let mut cursor = node.walk(); + // Decode static heredoc text without joining across an expansion. + for end in node + .named_children(&mut cursor) + .filter(|child| !quoted && child.kind() != "heredoc_content") + .map(Some) + .chain(std::iter::once(None)) + { + let text = &script[start..end.map_or(node.end_byte(), |child| child.start_byte())]; + let text = if strip_tabs { + text.split_inclusive('\n') + .map(|line| line.trim_start_matches('\t')) + .collect::() + } else { + text.to_string() + }; + let mut chars = text.chars().peekable(); + let mut decoded = String::new(); + while let Some(character) = chars.next() { + if !quoted && character == '\\' { + match chars.peek() { + Some('\n') => { + chars.next(); + continue; + } + Some('$' | '`' | '\\') => { + decoded.push(chars.next()?); + continue; + } + _ => {} + } + } + decoded.push(character); + } + words.push(decoded); + if let Some(child) = end { + start = child.end_byte(); + pending.push((child, quoting)); + } + } + continue; + } + let mut command_words: Vec<(std::ops::Range, Option>)> = Vec::new(); + let declaration = if node.kind() == "command" { + let mut cursor = node.walk(); + for argument in node + .child_by_field_name("name") + .into_iter() + .chain(node.children_by_field_name("argument", &mut cursor)) + { + let word = literal_word_bytes(argument, script, shell_type, quoting); + if shell_type == ShellType::Zsh + && let Some((previous_range, previous_word)) = command_words.last_mut() + && has_continuation_gap(script, previous_range.end, argument.start_byte()) + { + *previous_word = previous_word.take().zip(word).map(|(mut previous, word)| { + previous.extend(word); + previous + }); + previous_range.end = argument.end_byte(); + } else { + command_words.push((argument.byte_range(), word)); + } + } + let mut arguments = command_words.iter().map(|(_, word)| { + word.as_ref() + .map(|word| String::from_utf8_lossy(word).into_owned()) + }); + let mut name = arguments.next().flatten(); + loop { + match name.take().as_deref() { + Some( + name @ ("alias" | "declare" | "typeset" | "local" | "readonly" | "export"), + ) => { + break Some(name.to_string()); + } + Some("noglob" | "-") if shell_type == ShellType::Zsh => { + name = arguments.next().flatten(); + } + Some("time") => { + name = arguments.next().flatten(); + if shell_type == ShellType::Bash && name.as_deref() == Some("-p") { + name = arguments.next().flatten(); + } + } + Some(prefix @ ("builtin" | "command")) => { + name = arguments.next().flatten(); + if prefix == "command" { + while name.as_deref().is_some_and(|argument| { + argument.strip_prefix('-').is_some_and(|options| { + !options.is_empty() && options.bytes().all(|byte| byte == b'p') + }) + }) { + name = arguments.next().flatten(); + } + } + if (prefix == "command" + || prefix == "builtin" && shell_type != ShellType::Zsh) + && name.as_deref() == Some("--") + { + name = arguments.next().flatten(); + } + } + _ => break None, + } + } + } else { + None + }; + let is_alias = declaration.as_deref() == Some("alias"); + let may_initialize_array = shell_type == ShellType::Bash + && (node.kind() == "declaration_command" || declaration.is_some() && !is_alias); + if is_alias || may_initialize_array { + // Bash can inherit an array's type from an earlier declaration or calling scope. + // Aliases and quoted compound-array initializers are parsed again by the shell. + let mut cursor = node.walk(); + for argument in node.named_children(&mut cursor) { + if command_words.iter().any(|(range, _)| { + range.start < argument.start_byte() && argument.end_byte() <= range.end + }) { + continue; + } + let continued = command_words.iter().find(|(range, _)| { + range.start == argument.start_byte() && range.end > argument.end_byte() + }); + if let Some(assignment) = continued + .map_or_else( + || literal_word_bytes(argument, script, shell_type, quoting), + |(_, word)| word.clone(), + ) + .or_else(|| { + // A dynamic edge prevents full decoding, but not inspection of the + // static fragments in the rest of the continued argument. + let end = continued.map_or(argument.end_byte(), |(range, _)| range.end); + let mut pieces = std::iter::successors(Some(argument), |piece| { + piece.next_named_sibling() + }) + .take_while(|piece| piece.end_byte() <= end) + .collect::>(); + pieces.reverse(); + let mut word = Vec::new(); + while let Some(piece) = pieces.pop() { + if let Some(literal) = + literal_word_bytes(piece, script, shell_type, quoting) + { + word.extend(literal); + } else { + match piece.kind() { + "variable_assignment" => { + word.extend_from_slice(b"_="); + pieces.push(piece.child_by_field_name("value")?); + } + "concatenation" | "string" => pieces.extend( + (0..piece.named_child_count()) + .rev() + .filter_map(|index| piece.named_child(index)), + ), + "string_content" => { + let text = piece.utf8_text(script.as_bytes()).ok()?; + let decoded = shlex::split(&format!("\"{text}\""))?; + let [literal] = decoded.as_slice() else { + return None; + }; + word.extend_from_slice(literal.as_bytes()); + } + "simple_expansion" + | "expansion" + | "command_substitution" + | "process_substitution" + | "arithmetic_expansion" => { + word.extend_from_slice(b"${__codex_snapshot_dynamic}"); + } + _ => return None, + } + } + } + Some(word) + }) + .map(|word| String::from_utf8_lossy(&word).into_owned()) + && let Some((_, body)) = assignment.split_once('=') + { + let body = if may_initialize_array { + let body = body + .trim_start_matches("${__codex_snapshot_dynamic}") + .trim_end_matches("${__codex_snapshot_dynamic}"); + if !body.starts_with('(') || !body.ends_with(')') { + continue; + } + format!("_={body}") + } else { + body.to_string() + }; + for "ing in Quoting::deferred_modes(shell_type) { + if checked_aliases.insert((body.clone(), quoting)) { + words.extend(literal_words_with_quoting( + &body, + shell_type, + Parsing::Deferred(quoting), + checked_aliases, + )?); + } + } + } + } + } + let mut pieces = vec![node]; + if shell_type == ShellType::Zsh + && node.prev_named_sibling().is_none_or(|previous| { + !has_continuation_gap(script, previous.end_byte(), node.start_byte()) + }) + { + // Tree-sitter splits unquoted continuations into sibling words. Decode the + // pieces separately: removing the gap would change adjacent RC_QUOTES strings. + let mut previous = node; + while let Some(next) = previous.next_named_sibling() { + if !has_continuation_gap(script, previous.end_byte(), next.start_byte()) { + break; + } + pieces.push(next); + previous = next; + } + } + let continued = pieces.len() > 1; + if node.kind() == "ansi_c_string" { + words.push(decode_ansi_c( + node.utf8_text(script.as_bytes()).ok()?, + shell_type, + quoting, + )?); + if !continued { + continue; + } + } else if let Some(word) = literal_word(node, script, shell_type, quoting) { + words.push(word); + if !continued { + continue; + } + } + if continued || matches!(node.kind(), "string" | "concatenation") { + // Join adjacent static pieces, but never join across an expansion. + pieces.reverse(); + let mut literal = Vec::new(); + let mut raw_string_end = None; + while let Some(piece) = pieces.pop() { + if let Some(word) = literal_word_bytes(piece, script, shell_type, quoting) { + if matches!(quoting, Quoting::ZshRcQuotes) + && piece.kind() == "raw_string" + && raw_string_end == Some(piece.start_byte()) + { + literal.push(b'\''); + } + literal.extend(word); + raw_string_end = (piece.kind() == "raw_string").then_some(piece.end_byte()); + } else if piece.kind() == "variable_assignment" { + if !literal.is_empty() { + words.push(String::from_utf8_lossy(&literal).into_owned()); + literal.clear(); + } + // Only the value can continue into the next word, never the assignment name. + pieces.extend(piece.child_by_field_name("value")); + } else if piece.kind() == "string_content" { + let content = piece.utf8_text(script.as_bytes()).ok()?; + let decoded = shlex::split(&format!("\"{content}\""))?; + let [word] = decoded.as_slice() else { + return None; + }; + literal.extend_from_slice(word.as_bytes()); + } else if matches!(piece.kind(), "string" | "concatenation" | "command_name") { + if shell_type == ShellType::Zsh + && piece.prev_sibling().is_some_and(|previous| { + previous.kind() == "$" && previous.end_byte() == piece.start_byte() + }) + { + literal.push(b'$'); + } + pieces.extend( + (0..piece.named_child_count()) + .rev() + .filter_map(|index| piece.named_child(index)), + ); + } else if !literal.is_empty() { + words.push(String::from_utf8_lossy(&literal).into_owned()); + literal.clear(); + } + } + if !literal.is_empty() { + words.push(String::from_utf8_lossy(&literal).into_owned()); + } + } + let mut cursor = node.walk(); + pending.extend( + node.named_children(&mut cursor) + .map(|child| (child, quoting)), + ); + } + Some(words) +} + +fn has_continuation_gap(script: &str, left_end: usize, right_start: usize) -> bool { + let gap = &script[left_end..right_start]; + !gap.is_empty() && gap.as_bytes().chunks(2).all(|pair| pair == b"\\\n") +} + +fn literal_word( + node: Node<'_>, + script: &str, + shell_type: ShellType, + quoting: Quoting, +) -> Option { + let bytes = literal_word_bytes(node, script, shell_type, quoting)?; + Some(String::from_utf8_lossy(&bytes).into_owned()) +} + +pub(super) fn literal_word_bytes( + node: Node<'_>, + script: &str, + shell_type: ShellType, + quoting: Quoting, +) -> Option> { + let mut word = match node.kind() { + "command_name" => node + .named_child(0) + .and_then(|name| literal_word_bytes(name, script, shell_type, quoting)), + "concatenation" => { + let mut cursor = node.walk(); + let mut word = Vec::new(); + let mut raw_string_end = None; + for child in node.named_children(&mut cursor) { + // RC_QUOTES treats adjacent single-quoted strings as one quoted apostrophe. + if matches!(quoting, Quoting::ZshRcQuotes) + && child.kind() == "raw_string" + && raw_string_end == Some(child.start_byte()) + { + word.push(b'\''); + } + word.extend(literal_word_bytes(child, script, shell_type, quoting)?); + raw_string_end = (child.kind() == "raw_string").then_some(child.end_byte()); + } + Some(word) + } + "ansi_c_string" => { + decode_ansi_c_bytes(node.utf8_text(script.as_bytes()).ok()?, shell_type, quoting) + } + "word" | "number" | "string" | "raw_string" => { + let mut cursor = node.walk(); + if node + .named_children(&mut cursor) + .any(|child| child.kind() != "string_content") + { + return None; + } + let text = node.utf8_text(script.as_bytes()).ok()?; + let mut context = node.parent(); + while context + .is_some_and(|parent| matches!(parent.kind(), "expansion" | "concatenation")) + { + context = context.and_then(|parent| parent.parent()); + } + let words = if shell_type == ShellType::Zsh + && node.kind() == "word" + && context.is_some_and(|parent| parent.kind() == "string") + { + shlex::split(&format!("\"{text}\""))? + } else { + shlex::split(text)? + }; + let [word] = words.as_slice() else { + return None; + }; + Some(word.as_bytes().to_vec()) + } + _ => None, + }?; + if shell_type == ShellType::Zsh + && matches!(node.kind(), "string" | "concatenation") + && node.prev_sibling().is_some_and(|previous| { + previous.kind() == "$" && previous.end_byte() == node.start_byte() + }) + { + word.insert(0, b'$'); + } + Some(word) +} + +// Decode the literal quoting emitted by Bash and Zsh, never shell expansions. +fn decode_ansi_c(raw: &str, shell_type: ShellType, quoting: Quoting) -> Option { + let bytes = decode_ansi_c_bytes(raw, shell_type, quoting)?; + Some(String::from_utf8_lossy(&bytes).into_owned()) +} + +fn decode_ansi_c_bytes(raw: &str, shell_type: ShellType, quoting: Quoting) -> Option> { + let body = raw.strip_prefix("$'")?; + let body = body.strip_suffix('\'').or_else(|| { + // Array-shaped scalar templates may contain incomplete shell syntax. + (shell_type == ShellType::Bash).then_some(body) + })?; + let mut chars = body.chars().peekable(); + let mut decoded = Vec::new(); + let mut modifiers = ZshByteModifiers::default(); + while let Some(character) = chars.next() { + if character != '\\' { + let mut bytes = [0; 4]; + let bytes = character.encode_utf8(&mut bytes).as_bytes(); + decoded.push(modifiers.apply(bytes[0])); + decoded.extend_from_slice(&bytes[1..]); + continue; + } + let Some(escape) = chars.next() else { + if shell_type == ShellType::Zsh { + return None; + } + decoded.push(b'\\'); + break; + }; + let byte = match escape { + 'a' => 7, + 'b' => 8, + 'e' | 'E' => 27, + 'f' => 12, + 'n' => b'\n', + 'r' => b'\r', + 't' => b'\t', + 'v' => 11, + '\\' | '\'' | '"' | '?' => escape as u8, + 'c' if shell_type != ShellType::Zsh => { + let Some(character) = chars.next() else { + decoded.push(b'\\'); + if quoting != Quoting::LegacyBash { + decoded.push(b'c'); + } + break; + }; + // Bash 3.2 leaves the second backslash; newer Bash consumes the pair. + if character == '\\' + && quoting != Quoting::LegacyBash + && chars.peek() == Some(&'\\') + { + chars.next(); + } + let mut bytes = [0; 4]; + let bytes = character.encode_utf8(&mut bytes).as_bytes(); + decoded.push(if bytes[0] == b'?' { 127 } else { bytes[0] & 31 }); + decoded.extend_from_slice(&bytes[1..]); + continue; + } + 'C' | 'M' if shell_type == ShellType::Zsh => { + if chars.peek() == Some(&'-') { + chars.next(); + } + if escape == 'C' { + modifiers.control = true; + } else { + modifiers.meta = Some(if modifiers.control { + MetaOrder::BeforeControl + } else { + MetaOrder::AfterControl + }); + } + continue; + } + '0'..='7' | 'x' | 'u' | 'U' => { + if matches!(escape, 'u' | 'U') && quoting == Quoting::LegacyBash { + decoded.extend_from_slice(&[b'\\', escape as u8]); + continue; + } + let (radix, digits) = match escape { + 'x' => (16, 2), + 'u' => (16, 4), + 'U' => (16, 8), + _ => (8, 2), + }; + let mut value = escape.to_digit(8).unwrap_or(0); + let mut found = escape.is_ascii_digit(); + for _ in 0..digits { + let Some(digit) = chars.peek().and_then(|next| next.to_digit(radix)) else { + break; + }; + chars.next(); + value = value.checked_mul(radix)?.checked_add(digit)?; + found = true; + } + if !found { + if shell_type == ShellType::Zsh { + return None; + } + decoded.extend_from_slice(&[b'\\', escape as u8]); + continue; + } + if matches!(escape, 'u' | 'U') { + let Some(character) = char::from_u32(value) else { + if shell_type == ShellType::Zsh { + return None; + } + // Bash emits extended UTF-8, including surrogate and 5/6-byte values. + let length = match value { + 0..=0xffff => 3, + 0x10000..=0x1fffff => 4, + 0x200000..=0x3ffffff => 5, + 0x4000000..=0x7fffffff => 6, + _ => continue, + }; + let mut bytes = [0; 6]; + for index in (1..length).rev() { + bytes[index] = 0x80 | (value & 0x3f) as u8; + value >>= 6; + } + bytes[0] = (0xff << (8 - length)) | value as u8; + decoded.extend_from_slice(&bytes[..length]); + continue; + }; + // Zsh leaves byte modifiers pending across Unicode escapes. + let mut bytes = [0; 4]; + decoded.extend_from_slice(character.encode_utf8(&mut bytes).as_bytes()); + continue; + } + value as u8 + } + _ => { + if shell_type != ShellType::Zsh { + decoded.push(b'\\'); + } + let mut bytes = [0; 4]; + let bytes = escape.encode_utf8(&mut bytes).as_bytes(); + decoded.push(modifiers.apply(bytes[0])); + decoded.extend_from_slice(&bytes[1..]); + continue; + } + }; + decoded.push(modifiers.apply(byte)); + } + Some(decoded) +} + +enum MetaOrder { + BeforeControl, + AfterControl, +} + +#[derive(Default)] +struct ZshByteModifiers { + control: bool, + meta: Option, +} + +impl ZshByteModifiers { + fn apply(&mut self, mut byte: u8) -> u8 { + // Escape order matters for control-? and bytes whose high bit is already set. + let meta = self.meta.take(); + if matches!(meta, Some(MetaOrder::BeforeControl)) { + byte |= 0x80; + } + if std::mem::take(&mut self.control) { + byte = if byte == b'?' { 0x7f } else { byte & 0x9f }; + } + if matches!(meta, Some(MetaOrder::AfterControl)) { + byte |= 0x80; + } + byte + } +} diff --git a/codex-rs/shell-command/src/shell_snapshot_render.rs b/codex-rs/shell-command/src/shell_snapshot_render.rs index 1881988bdf..ead034d38c 100644 --- a/codex-rs/shell-command/src/shell_snapshot_render.rs +++ b/codex-rs/shell-command/src/shell_snapshot_render.rs @@ -32,8 +32,20 @@ pub(super) enum ValuePart { pub(super) enum Export<'a> { Captured(&'a str), - Alias { key: &'a str, value: Value }, - Assignment { declaration: &'a str, value: Value }, + Assignment { + declaration: &'a str, + value: Value, + }, + Array { + prefix: &'a str, + elements: Vec, + suffix: &'a str, + }, + ArrayBinding { + key: &'a str, + declaration: &'a str, + suffix: &'a str, + }, } impl Value { @@ -64,12 +76,30 @@ pub(super) fn render(state: &str, aliases: &str, exports: &[Export<'_>]) -> Opti for export in exports { match export { Export::Captured(source) => output.push_str(source), - Export::Alias { key, value } => { - output.push_str(&format!("export {key}={}\n", value.render()?)); - } Export::Assignment { declaration, value } => { output.push_str(&format!("{declaration}={}\n", value.render()?)); } + Export::Array { + prefix, + elements, + suffix, + } => { + let elements = elements + .iter() + .map(Value::render) + .collect::>>()?; + output.push_str(&format!("{prefix}({}){suffix}", elements.join(" "))); + } + Export::ArrayBinding { + key, + declaration, + suffix, + } => { + output.push_str(&format!( + "if [ \"${{{key}+x}}\" = x ]; then\n{declaration}{}\nfi\n", + suffix.trim_end() + )); + } } } Some(output) diff --git a/codex-rs/shell-command/src/shell_snapshot_tests.rs b/codex-rs/shell-command/src/shell_snapshot_tests.rs index bd68bd34c5..c1d39964ba 100644 --- a/codex-rs/shell-command/src/shell_snapshot_tests.rs +++ b/codex-rs/shell-command/src/shell_snapshot_tests.rs @@ -10,15 +10,38 @@ use anyhow::Context; use anyhow::Result; use pretty_assertions::assert_eq; use std::collections::HashMap; +use std::os::unix::process::CommandExt; use std::process::Command; use tempfile::tempdir; +fn empty_snapshot_credentials<'a>( + empty: &'a HashMap, + is_allowed_unset: &'a dyn Fn(&str) -> bool, +) -> SnapshotCredentialEnvironment<'a> { + SnapshotCredentialEnvironment { + original: empty, + restored: empty, + configured: empty, + discovered: empty, + allowed: empty, + is_allowed_unset, + brokered_keys: &[], + brokered_alias_keys: &[], + allowed_brokered_keys: &[], + } +} + const CAPTURE_ALL: SnapshotCaptureOptions = SnapshotCaptureOptions { startup: SnapshotStartup::Interactive, declarations: true, environment: true, }; +const CAPTURE_NON_INTERACTIVE: SnapshotCaptureOptions = SnapshotCaptureOptions { + startup: SnapshotStartup::NonInteractive, + ..CAPTURE_ALL +}; + fn snapshot_script(shell_type: ShellType) -> Option { snapshot_capture_script(shell_type, CAPTURE_ALL) } @@ -493,6 +516,1308 @@ fn brokered_snapshots_filter_complete_multiline_exports() -> Result<()> { Ok(()) } +#[test] +fn brokered_zsh_snapshot_preserves_tied_credential_aliases() -> Result<()> { + if !std::path::Path::new("/bin/zsh").exists() { + return Ok(()); + } + let dir = tempdir()?; + let real = "password$abcdefghijklmno"; + let dummy = "dummy-0123456789abcdef"; + let last = "ordinary'quote\nnext"; + let brokered_keys = vec!["VENDOR_PASSWORD".to_string()]; + for separator in [":", "|", "'", "\t", "$", "_", "x"] { + let appended = format!("ordinary{separator}other"); + for rcquotes in [false, true] { + let script = snapshot_capture_script(ShellType::Zsh, CAPTURE_NON_INTERACTIVE).unwrap(); + let setup = format!( + "{}\nexport -UT SERVICE_URLS service_urls=( \"https://user:$VENDOR_PASSWORD@example.invalid/path\" '' {} ) {}\nservice_urls+=({})\n{script}", + if rcquotes { + "setopt rcquotes rcexpandparam" + } else { + ":" + }, + shlex::try_quote(last)?, + shlex::try_quote(separator)?, + shlex::try_quote(&appended)?, + ); + let original = HashMap::from([ + ("VENDOR_PASSWORD".to_string(), real.to_string()), + ( + "SERVICE_URLS".to_string(), + [ + format!("https://user:{real}@example.invalid/path"), + String::new(), + last.to_string(), + appended.clone(), + ] + .join(separator), + ), + ]); + let captured = Command::new("/bin/zsh") + .args(["-fc", &setup]) + .env_clear() + .env("VENDOR_PASSWORD", real) + .env("PATH", "/usr/bin:/bin") + .output()?; + assert!(captured.status.success(), "{captured:?}"); + let mut snapshot = String::from_utf8(captured.stdout)?; + let allowed = original + .iter() + .map(|(key, value)| (key.clone(), value.replace(real, dummy))) + .collect::>(); + let rewritten = rewrite_snapshot_credentials( + ShellType::Zsh, + &mut snapshot, + SnapshotCredentialEnvironment { + original: &original, + restored: &original, + configured: &HashMap::new(), + discovered: &allowed, + allowed: &allowed, + is_allowed_unset: &|_| true, + brokered_keys: &brokered_keys, + brokered_alias_keys: &[], + allowed_brokered_keys: &brokered_keys, + }, + |text| { + *text = text.replace(real, dummy); + true + }, + ) + .expect("tied export can be brokered"); + assert_eq!( + rewritten.aliases, + HashMap::from([("SERVICE_URLS".to_string(), allowed["SERVICE_URLS"].clone())]) + ); + assert!(!snapshot.contains(real)); + let path = dir.path().join("snapshot.sh"); + std::fs::write(&path, &snapshot)?; + for live in [dummy, real] { + let replayed = Command::new("/bin/zsh") + .args(["-fc", "set -e; . \"$1\"; printf '%s\\0' \"$SERVICE_URLS\" \"${service_urls[@]}\" \"$options[rcquotes]\"", "snapshot"]) + .arg(&path) + .env_clear() + .env("VENDOR_PASSWORD", live) + .output()?; + assert!(replayed.status.success(), "{replayed:?}"); + let scalar = original["SERVICE_URLS"].replace(real, live); + let mut array = vec![ + format!("https://user:{live}@example.invalid/path"), + String::new(), + last.to_string(), + ]; + array.push(appended.clone()); + assert_eq!( + replayed.stdout, + format!( + "{scalar}\0{}\0{}\0", + array.join("\0"), + if rcquotes { "on" } else { "off" } + ) + .into_bytes() + ); + } + } + } + for unique in [false, true] { + for separator in [":", "|", "'", "\t"] { + let flags = if unique { "-UT" } else { "-T" }; + let separator = shlex::try_quote(separator)?; + let binding = format!("export {flags} SERVICE_URLS service_urls {separator}"); + let script = snapshot_capture_script(ShellType::Zsh, CAPTURE_NON_INTERACTIVE).unwrap(); + let captured = Command::new("/bin/zsh") + .args([ + "-fc", + &format!( + "export {flags} SERVICE_URLS service_urls=( '' \"$VENDOR_PASSWORD\" '' plain plain '' ) {separator}\nunset VENDOR_PASSWORD\n{script}" + ), + ]) + .env_clear() + .env("VENDOR_PASSWORD", real) + .env("PATH", "/usr/bin:/bin") + .output()?; + assert!(captured.status.success(), "{captured:?}"); + let mut snapshot = String::from_utf8(captured.stdout)?; + let original = CapturedSnapshot::parse(ShellType::Zsh, snapshot.as_bytes()) + .unwrap() + .environment + .split(|byte| *byte == 0) + .filter_map(|entry| std::str::from_utf8(entry).ok()?.split_once('=')) + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect::>(); + assert!(!original.contains_key("VENDOR_PASSWORD")); + let allowed = original + .iter() + .map(|(key, value)| (key.clone(), value.replace(real, dummy))) + .collect::>(); + let alias_keys = vec!["SERVICE_URLS".to_string()]; + let rewritten = rewrite_snapshot_credentials( + ShellType::Zsh, + &mut snapshot, + SnapshotCredentialEnvironment { + original: &original, + restored: &original, + configured: &HashMap::new(), + discovered: &allowed, + allowed: &allowed, + is_allowed_unset: &|_| true, + brokered_keys: &[], + brokered_alias_keys: &alias_keys, + allowed_brokered_keys: &[], + }, + |text| { + *text = text.replace(real, dummy); + true + }, + ) + .expect("source-less tied alias can be brokered"); + assert_eq!(rewritten.aliases, HashMap::new()); + assert!(!snapshot.contains(real)); + assert!(!snapshot.contains(dummy)); + let path = dir.path().join("source-less-snapshot.sh"); + std::fs::write(&path, &snapshot)?; + let inspect = "printf '%s\\0' \"${SERVICE_URLS-unset}\" \"${service_urls[@]}\" \"${(t)service_urls}\" \"${VENDOR_PASSWORD-unset}\"; if [ -n \"${SERVICE_URLS+x}\" ]; then SERVICE_URLS=$UPDATE; printf '%s\\0' \"$SERVICE_URLS\" \"${service_urls[@]}\"; unset SERVICE_URLS; fi; printf '%s:%s' \"${+SERVICE_URLS}\" \"${+service_urls}\""; + for live in [Some(allowed["SERVICE_URLS"].as_str()), Some(""), None] { + for source in [None, Some("different-live-source")] { + let mut outputs = Vec::new(); + for script in [ + format!("set -e; . \"$1\"; {inspect}"), + format!( + "set -e; if [ -n \"${{SERVICE_URLS+x}}\" ]; then {binding}; fi; {inspect}" + ), + ] { + let mut command = Command::new("/bin/zsh"); + command + .args(["-fc", &script, "snapshot"]) + .arg(&path) + .env_clear() + .env( + "UPDATE", + allowed["SERVICE_URLS"].replace(dummy, "new-live-dummy"), + ); + if let Some(live) = live { + command.env("SERVICE_URLS", live); + } + if let Some(source) = source { + command.env("VENDOR_PASSWORD", source); + } + let output = command.output()?; + assert!(output.status.success(), "{script}: {output:?}"); + outputs.push(String::from_utf8(output.stdout)?); + } + assert_eq!(outputs[0], outputs[1], "{binding}, {live:?}, {source:?}"); + } + } + } + } + Ok(()) +} + +#[test] +fn brokered_snapshot_rejects_credentials_crossing_array_elements() { + let real = "password$abcdefghijklmno"; + let dummy = "dummy-0123456789abcdef"; + let keys = vec!["VENDOR_PASSWORD".to_string()]; + let restored = HashMap::from([(keys[0].clone(), real.to_string())]); + for (captured_value, allowed_value) in [(real, dummy), (real, real), (dummy, dummy)] { + let original = HashMap::from([ + (keys[0].clone(), captured_value.to_string()), + ("AUTH_HEADER".to_string(), captured_value.to_string()), + ]); + let allowed = HashMap::from([ + (keys[0].clone(), allowed_value.to_string()), + ("AUTH_HEADER".to_string(), allowed_value.to_string()), + ]); + let (first, last) = captured_value.split_at(/*mid*/ 10); + let data = format!( + "# Snapshot file\n\0\0AUTH_HEADER\0typeset -xT AUTH_HEADER header=( '{first}' '{last}' ) ''\n\0\0" + ); + let capture = CapturedSnapshot::parse(ShellType::Zsh, data.as_bytes()).unwrap(); + let prepared = prepare_snapshot_credentials( + &capture, + SnapshotCredentialEnvironment { + original: &original, + restored: &restored, + configured: &HashMap::new(), + discovered: &allowed, + allowed: &allowed, + is_allowed_unset: &|_| true, + brokered_keys: &keys, + brokered_alias_keys: &[], + allowed_brokered_keys: &keys, + }, + |text| { + *text = text.replace(real, allowed_value); + true + }, + ); + assert!(prepared.is_none(), "{captured_value}, {allowed_value}"); + } +} + +#[test] +fn zsh_literal_bytes_match_native_modifiers() -> Result<()> { + if !std::path::Path::new("/bin/zsh").exists() { + return Ok(()); + } + for word in [ + r"$'\M-C\M-)'", + r"$'\xC3'$'\M-)'", + r"$'\M-\C-?'", + r"$'\C-\M-?'", + r"$'\C-\xC3'", + r"$'\M\x41'", + r"$'\M-\u0041X'", + r"$'\M-\q'", + ] { + let script = format!("printf '%s' {word}"); + let native = Command::new("/bin/zsh") + .args(["-fc", &script]) + .env_clear() + .output()?; + assert!(native.status.success(), "{word}: {native:?}"); + let tree = crate::bash::try_parse_shell(&script).unwrap(); + let node = tree.root_node().named_child(0).unwrap(); + let mut cursor = node.walk(); + let argument = node + .children_by_field_name("argument", &mut cursor) + .last() + .unwrap(); + let decoded = super::literals::literal_word_bytes( + argument, + &script, + ShellType::Zsh, + super::literals::Quoting::Posix, + ) + .unwrap(); + assert_eq!(decoded, native.stdout, "{word}"); + } + Ok(()) +} + +#[test] +fn brokered_snapshots_check_complete_alias_literals() -> Result<()> { + let real = "token_ab'cdefghijklmnopqrstuvwx"; + let original = HashMap::from([("VENDOR_PASSWORD".to_string(), real.to_string())]); + let allowed = HashMap::from([( + "VENDOR_PASSWORD".to_string(), + "token_dummy_abcdefghijklmnopqr".to_string(), + )]); + let brokered_keys = vec!["VENDOR_PASSWORD".to_string()]; + let controls = (1u8..=31) + .map(char::from) + .chain(['\u{7f}', '\u{e9}']) + .collect::(); + for (shell, aliases) in [("/bin/bash", "alias -p"), ("/bin/zsh", "alias -L")] { + let shell_type = crate::shell_detect::detect_shell_type(shell).unwrap(); + if !std::path::Path::new(shell).exists() { + continue; + } + for (value, accepted) in [ + (format!("printf '%s' \"{real}\""), false), + (format!("printf '%s' \"{real}\"\n:"), false), + ( + "printf %s token_ab\\'cdefghijklmnopqrstuvwx\n:".to_string(), + false, + ), + ( + "printf %s 'token_ab'\\''cdefghijklmnopqrstuvwx'\n:".to_string(), + false, + ), + ( + "printf '%s' \"ordinary'quoted\"\n# tab\tbell\u{7}escape\u{1b}backslash\\\n:" + .to_string(), + true, + ), + (format!("printf '%s' \"{controls}\""), true), + ] { + let output = Command::new(shell) + .args(["-c", &format!("alias helper=\"$ALIAS_VALUE\"; {aliases}")]) + .env_clear() + .env("ALIAS_VALUE", &value) + .output()?; + assert!(output.status.success(), "{shell}: {output:?}"); + let mut snapshot = format!( + "# aliases\n{}\n# exports 0\n", + String::from_utf8(output.stdout)? + ); + let before = snapshot.clone(); + let rewritten = rewrite_snapshot_credentials( + shell_type, + &mut snapshot, + SnapshotCredentialEnvironment { + original: &original, + restored: &original, + configured: &HashMap::new(), + discovered: &allowed, + allowed: &allowed, + is_allowed_unset: &|_| false, + brokered_keys: &brokered_keys, + brokered_alias_keys: &[], + allowed_brokered_keys: &brokered_keys, + }, + |text| { + *text = text.replace(real, &allowed["VENDOR_PASSWORD"]); + true + }, + ); + assert_eq!(rewritten.is_some(), accepted, "{shell}: {before}"); + if accepted { + assert_eq!(snapshot, before); + let expected = if shell_type == ShellType::Zsh { + let replay = Command::new(shell) + .args(["-fc", &format!("{snapshot}\nprint -rn -- $aliases[helper]")]) + .env_clear() + .output()?; + assert!(replay.status.success(), "{replay:?}"); + String::from_utf8(replay.stdout)? + } else { + value.clone() + }; + assert!( + super::literals::literal_words(&snapshot, shell_type) + .unwrap() + .contains(&format!("helper={expected}")), + "{shell}: {value:?}; {snapshot}" + ); + } + } + } + Ok(()) +} + +#[test] +fn brokered_snapshots_check_prefixed_alias_declarations() -> Result<()> { + let empty = HashMap::new(); + let real = "token_ab'cd\"efghijklmnopqrstuvwx"; + let body = format!("printf '%s' {}", shlex::try_quote(real)?); + for (shell, shell_type, setup) in [ + ("/bin/bash", ShellType::Bash, "shopt -s expand_aliases"), + ("/bin/zsh", ShellType::Zsh, "setopt posixbuiltins"), + ] { + if !std::path::Path::new(shell).exists() { + continue; + } + for (prefix, creates_alias, name) in [ + ("alias", true), + ("builtin alias", true), + ("builtin builtin alias", true), + ("time builtin alias", true), + ("command alias", true), + ("command -p -- alias", true), + ("command -pp alias", true), + ("command builtin alias", true), + ("builtin command -p alias", true), + ("command -v alias", false), + ("command -pV alias", false), + ] + .into_iter() + .chain((shell_type == ShellType::Bash).then_some(("builtin -- alias", true))) + .chain((shell_type == ShellType::Bash).then_some(("time -p builtin alias", true))) + .chain( + (shell_type == ShellType::Zsh) + .then_some([ + ("noglob builtin alias", true), + ("builtin - alias", true), + ("- builtin alias", true), + ]) + .into_iter() + .flatten(), + ) + .flat_map(|(prefix, creates_alias)| { + ["captured", "\"$name\""].map(|name| (prefix, creates_alias, name)) + }) { + let capture = format!( + "helper() {{ local name=captured; {prefix} {name}={}; }}\ntypeset -f helper", + shlex::try_quote(&body)? + ); + let output = Command::new(shell) + .args(["-fc", &capture]) + .env_clear() + .output()?; + assert!(output.status.success(), "{shell}: {output:?}"); + let original = String::from_utf8(output.stdout)?; + assert!(!original.contains(real), "{shell}: {original}"); + for contains_credential in [false, true] { + let mut snapshot = original.clone(); + let result = rewrite_snapshot_credentials( + shell_type, + &mut snapshot, + empty_snapshot_credentials(&empty, &|_| false), + |text| { + if contains_credential { + *text = text.replace(real, "dummy"); + } + true + }, + ); + assert_eq!( + result.is_some(), + !contains_credential || !creates_alias, + "{shell}, {prefix}: {snapshot}" + ); + assert_eq!(snapshot, original); + if !contains_credential && creates_alias { + let replay = Command::new(shell) + .args([ + "-fc", + &format!("{setup}\n{snapshot}\nhelper\neval captured"), + ]) + .env_clear() + .output()?; + assert!(replay.status.success(), "{shell}, {prefix}: {replay:?}"); + assert_eq!(String::from_utf8(replay.stdout)?, real); + } + } + } + } + Ok(()) +} + +#[test] +fn brokered_snapshots_check_array_initializers_and_binary_literals() { + let empty = HashMap::new(); + let real = "password$abcdefghijklmno"; + for (shell_type, original, has_credential) in [ + ( + ShellType::Bash, + r#"declare -ax API_HEADERS='([0]="Bearer password\$abcdefghijklmno")'"#, + ), + ( + ShellType::Bash, + r#"builtin declare -ax API_HEADERS='([0]="Bearer password\$abcdefghijklmno")'"#, + ), + ( + ShellType::Bash, + r#"command -p typeset -ax API_HEADERS='([0]="Bearer password\$abcdefghijklmno")'"#, + ), + ( + ShellType::Bash, + r#"declare -Ax API_HEADERS=([auth]="Bearer password\$abcdefghijklmno")"#, + ), + ( + ShellType::Bash, + r#"helper() { local -a headers; local headers='([0]="Bearer password\$abcdefghijklmno")'; }"#, + ), + ( + ShellType::Bash, + r#"declare -a headers; builtin declare headers='([0]="Bearer password\$abcdefghijklmno")'"#, + ), + ( + ShellType::Bash, + r#"declare -a headers; command -p typeset headers='([0]="Bearer password\$abcdefghijklmno")'"#, + ), + ( + ShellType::Bash, + r#"declare SCALAR_TEMPLATE='([0]="Bearer password\$abcdefghijklmno")'"#, + ), + ( + ShellType::Bash, + r#"helper() { local name=headers; local -a headers; local "${name}"='([0]="Bearer password\$abcdefghijklmno")'; }"#, + ), + ( + ShellType::Bash, + r#"helper() { local -a headers; local headers='([0]="Bearer password\$abcdefghijklmno" [1]="'"$OTHER"'")'; }"#, + ), + ( + ShellType::Bash, + r#"helper() { local -a headers; local headers=$PREFIX'([0]="Bearer password\$abcdefghijklmno")'$SUFFIX; }"#, + ), + ( + ShellType::Zsh, + r#"helper() { printf '%s' $'\x89password\x24abcdefghijklmno\xff'; }"#, + ), + ] + .into_iter() + .map(|(shell_type, original)| (shell_type, original, true)) + .chain( + [ + r#"local TEMPLATE="(\$'\\xZZ')""#, + r#"local TEMPLATE="(\$'\\uD800')""#, + r#"local TEMPLATE="(\$'\\UFFFFFFFF')""#, + r#"local TEMPLATE="(\$'\\c')""#, + r#"local TEMPLATE="(\$'\\')""#, + "local TEMPLATE=\"(\\$'\\\\c\u{e9}')\"", + r#"local TEMPLATE="(\$'incomplete)""#, + r#"local -a headers; local headers='([0]="Bearer '$CREDENTIAL'")'"#, + ] + .map(|original| (ShellType::Bash, original, false)), + ) { + for contains_credential in [false, true] { + let mut snapshot = original.to_string(); + let result = rewrite_snapshot_credentials( + shell_type, + &mut snapshot, + empty_snapshot_credentials(&empty, &|_| true), + |text| { + if contains_credential { + *text = text.replace(real, "dummy"); + } + true + }, + ); + assert_eq!( + result.is_some(), + !contains_credential || !has_credential, + "{original}" + ); + assert_eq!(snapshot, original); + } + } +} + +#[test] +fn brokered_sh_snapshots_inspect_the_captured_shell_dialect() -> Result<()> { + let empty = HashMap::new(); + let real = "password$abcdefghijklmno"; + let script = snapshot_capture_script(ShellType::Sh, CAPTURE_NON_INTERACTIVE).unwrap(); + for (shell, bash_backed) in [("/bin/bash", true), ("/bin/dash", false)] { + if !std::path::Path::new(shell).exists() { + continue; + } + for (setup, contains_credential) in [ + ( + r#"helper() { local -a headers; local headers='([0]="Bearer password\$abcdefghijklmno")'; }"#, + true, + ), + ( + r#"helper() { time -p builtin alias captured='printf "%s" "password\$abcdefghijklmno"'; }"#, + true, + ), + ( + r#"helper() { local name=captured; alias "$name"='printf "%s" "password\$abcdefghijklmno"'; }"#, + true, + ), + ( + r#"export TEMPLATE='([0]="Bearer password\$abcdefghijklmno")'"#, + true, + ), + (r#"export TEMPLATE="(\$'incomplete)""#, false), + ( + r#"helper() { local -a headers; local headers='([0]="ordinary\$value")'; }; export TEMPLATE=ordinary"#, + false, + ), + ] { + let capture = Command::new(shell) + .arg0("sh") + .args(["-c", &format!("{setup}\n{script}")]) + .env_clear() + .env("PATH", "/usr/bin:/bin") + .output()?; + assert!(capture.status.success(), "{shell}: {capture:?}"); + let original = String::from_utf8(capture.stdout)?; + assert_eq!( + original.starts_with(super::BASH_SH_SNAPSHOT_HEADER), + bash_backed + ); + let mut snapshot = original.clone(); + let result = rewrite_snapshot_credentials( + ShellType::Sh, + &mut snapshot, + empty_snapshot_credentials(&empty, &|_| true), + |text| { + *text = text.replace(real, "dummy"); + true + }, + ); + assert_eq!( + result.is_some(), + !bash_backed || !contains_credential, + "{shell}: {setup}" + ); + assert_eq!( + snapshot, + if result.is_some() { + captured_script(ShellType::Sh, &original)? + } else { + original + } + ); + if result.is_some() { + let replay = Command::new(shell) + .arg0("sh") + .args(["-c", &format!("{snapshot}\nprintf '%s' \"${{TEMPLATE-}}\"")]) + .env_clear() + .output()?; + let control = Command::new(shell) + .arg0("sh") + .args(["-c", &format!("{setup}\nprintf '%s' \"${{TEMPLATE-}}\"")]) + .env_clear() + .output()?; + assert!(replay.status.success(), "{shell}: {replay:?}"); + assert!(control.status.success(), "{shell}: {control:?}"); + assert_eq!(replay.stdout, control.stdout); + } + } + } + Ok(()) +} + +#[test] +fn brokered_snapshots_check_native_quoting() -> Result<()> { + let empty = HashMap::new(); + for (shell, shell_type, aliases) in [ + ("/bin/bash", ShellType::Bash, "alias -p"), + ("/bin/zsh", ShellType::Zsh, "alias -L"), + ] { + if !std::path::Path::new(shell).exists() { + continue; + } + let mut expressions = vec![ + r#""`printf '%s' 'password\$abcdefghijklmno'`""#, + r#"`printf '%s' 'password\$abcdefghijklmno'`"#, + r#""`printf '%s' \`printf '%s' 'password\\\$abcdefghijklmno'\``""#, + r#""${EMPTY-`printf '%s' 'password\"abcdefghijklmno'`}""#, + r#"$"token_ab'cdefghijklmnopqrstuvwx""#, + r#"prefix$"token_ab'cdefghijklmnopqrstuvwx""#, + r#"$"token_ab'cdefghijklmnopqrstuvwx""${EMPTY-}""#, + r#"$'token_\c\\abcdefghijklmnopqrstuvwx'"#, + r#""$(printf '%s' $'token_\c\\abcdefghijklmnopqrstuvwx')""#, + r#"$'token_\C-Aabcdefghijklmnopqrstuvwx'"#, + r#"$'token_\C-\x41abcdefghijklmnopqrstuvwx'"#, + r#"$'token_\C-\101abcdefghijklmnopqrstuvwx'"#, + r#"$'token_\C-\u0041abcdefghijklmnopqrstuvwx'"#, + r#"$'token_\C\101abcdefghijklmnopqrstuvwx'"#, + ]; + let mut array_expressions = Vec::new(); + if shell_type == ShellType::Bash { + expressions.extend([ + r#""$(array_value() { local -a headers; local headers='([0]="password\$abcdefghijklmno")'; printf '%s' "${headers[0]}"; }; array_value)""#, + r#""$(declare -a headers; builtin declare headers='([0]="password\$abcdefghijklmno")'; printf '%s' "${headers[0]}")""#, + r#""$(declare -a headers; command -p typeset headers='([0]="password\$abcdefghijklmno")'; printf '%s' "${headers[0]}")""#, + r#""$(array_value() { local name=headers; local -a headers; local "${name}"='([0]="password\$abcdefghijklmno")'; printf '%s' "${headers[0]}"; }; array_value)""#, + r#""$(array_value() { local -a headers; local headers='([0]="password\$abcdefghijklmno" [1]="'"$OTHER"'")'; printf '%s' "${headers[0]}"; }; array_value)""#, + r#""$(array_value() { local -a headers; local headers=$PREFIX'([0]="password\$abcdefghijklmno")'$SUFFIX; printf '%s' "${headers[0]}"; }; array_value)""#, + ]); + for escape in [r"\xZZ", r"\uD800", r"\UFFFFFFFF", r"\c"] { + let initializer = format!(r#"([0]=$'{escape}' [1]="password\$abcdefghijklmno")"#); + let initializer = shlex::try_quote(&initializer)?; + array_expressions.push(format!( + "\"$(declare -a headers; declare headers={initializer}; printf '%s' \"${{headers[1]}}\")\"" + )); + } + } else if shell_type == ShellType::Zsh { + expressions.extend([ + "password_a$'\\xc3'$'\\xa9'bcdefghijklmno\"${EMPTY-}\"", + "password_a$'\\M-C'$'\\M-)'bcdefghijklmno\"${EMPTY-}\"", + "password_a$'\\xc3'\\\n$'\\xa9'bcdefghijklmno\"${EMPTY-}\"", + "password\\\n\\$abcdefghijklmno", + "pass\\\nword\\\n\\\n\\$abcdefghijklmno", + "'password'\\\n'$abcdefghijklmno'", + "\"password\"\\\n'$abcdefghijklmno'", + "$'pass'\"word\"\\\n'$abcdefghijklmno'", + ]); + } + for expression in expressions + .into_iter() + .map(str::to_string) + .chain(array_expressions) + { + let body = format!("printf '%s' {expression}"); + let native = Command::new(shell) + .args(["-fc", &body]) + .env_clear() + .output()?; + assert!(native.status.success(), "{shell}, {expression}: {native:?}"); + let real = String::from_utf8(native.stdout)?; + assert!(real.len() >= 16, "{shell}, {expression}: {real:?}"); + let mut captures = vec![ + format!("helper() {{ {body}; }}\ntypeset -f helper"), + format!("alias helper=\"$ALIAS_VALUE\"\n{aliases}"), + ]; + if shell_type == ShellType::Zsh { + captures.push(format!("alias -g helper=\"$ALIAS_VALUE\"\n{aliases}")); + let script = snapshot_capture_script(shell_type, CAPTURE_NON_INTERACTIVE) + .expect("zsh supports snapshots"); + captures.push(format!( + "quoted() {{ cat <<'EOF'\n\" ${{(@f)EMPTY:-password\\$abcdefghijklmno}}\nEOF\n}}\nhelper() {{ {body}; }}\n{script}" + )); + } + for capture in captures { + let output = Command::new(shell) + .args(["-fc", &capture]) + .env_clear() + .env("ALIAS_VALUE", &body) + .output()?; + assert!(output.status.success(), "{shell}: {output:?}"); + let captured = String::from_utf8(output.stdout)?; + let original = if captured.contains('\0') { + let capture = CapturedSnapshot::parse(shell_type, captured.as_bytes()).unwrap(); + format!("{}{}", capture.state, capture.aliases) + } else { + captured + }; + for contains_credential in [false, true] { + let mut snapshot = original.clone(); + let result = rewrite_snapshot_credentials( + shell_type, + &mut snapshot, + empty_snapshot_credentials(&empty, &|_| false), + |text| { + if contains_credential { + *text = text.replace(&real, "dummy"); + } + true + }, + ); + assert_eq!( + result.is_some(), + !contains_credential, + "{shell}, {expression}: {snapshot}" + ); + assert_eq!(snapshot, original); + if !contains_credential { + let setup = if shell_type == ShellType::Bash { + "shopt -s expand_aliases\n" + } else { + "" + }; + let replay = Command::new(shell) + .args(["-fc", &format!("{setup}{snapshot}\neval helper")]) + .env_clear() + .output()?; + assert!( + replay.status.success(), + "{shell}, {expression}: {snapshot}\n{replay:?}" + ); + assert_eq!(String::from_utf8(replay.stdout)?, real); + } + } + } + } + } + Ok(()) +} + +#[test] +fn zsh_snapshot_checks_functions_before_restoring_quote_options() -> Result<()> { + if !std::path::Path::new("/bin/zsh").exists() { + return Ok(()); + } + let dir = tempdir()?; + let real = "ghp_abcdefghijklmnopqrstuvwxyz0123456789"; + let original = HashMap::from([("GH_TOKEN".to_string(), real.to_string())]); + let allowed = HashMap::from([("GH_TOKEN".to_string(), "dummy-github-token".to_string())]); + let keys = vec!["GH_TOKEN".to_string()]; + let script = snapshot_capture_script(ShellType::Zsh, CAPTURE_NON_INTERACTIVE) + .expect("zsh supports snapshots"); + for option in ["setopt rcquotes", "unsetopt rcquotes"] { + for (value, accepted) in [(real, false), ("ordinaryvalue", true)] { + let (first, second) = value.split_at(value.len() / 2); + let startup = dir.path().join("startup.sh"); + std::fs::write( + &startup, + format!("helper() {{ printf '%s' '{first}''{second}'; }}\n{option}\n"), + )?; + let output = Command::new("/bin/zsh") + .args(["-fc", &format!(". \"$1\"\n{script}"), "snapshot"]) + .arg(startup) + .env_clear() + .envs(&original) + .env("HOME", dir.path()) + .output()?; + assert!(output.status.success(), "{output:?}"); + let mut snapshot = String::from_utf8(output.stdout)?; + let rewritten = rewrite_snapshot_credentials( + ShellType::Zsh, + &mut snapshot, + SnapshotCredentialEnvironment { + original: &original, + restored: &original, + configured: &HashMap::new(), + discovered: &allowed, + allowed: &allowed, + is_allowed_unset: &|_| false, + brokered_keys: &keys, + brokered_alias_keys: &[], + allowed_brokered_keys: &keys, + }, + |text| { + *text = text.replace(real, &allowed["GH_TOKEN"]); + true + }, + ); + assert_eq!(rewritten.is_some(), accepted, "{option}: {snapshot}"); + if accepted { + let path = dir.path().join("snapshot.sh"); + std::fs::write(&path, &snapshot)?; + let replay = Command::new("/bin/zsh") + .args(["-fc", ". \"$1\"; helper", "snapshot"]) + .arg(path) + .env_clear() + .envs(&allowed) + .output()?; + assert!(replay.status.success(), "{replay:?}"); + assert_eq!(String::from_utf8(replay.stdout)?, value); + } + } + } + Ok(()) +} + +#[test] +fn zsh_snapshot_checks_deferred_quote_modes() -> Result<()> { + if !std::path::Path::new("/bin/zsh").exists() { + return Ok(()); + } + let dir = tempdir()?; + let script = snapshot_capture_script(ShellType::Zsh, CAPTURE_NON_INTERACTIVE) + .expect("zsh supports snapshots"); + for (real, startup) in [ + ( + "token_ab'cdefghijklmnopqrstuvwx", + "helper() { printf '%s' \"$(printf '%s' 'token_ab''cdefghijklmnopqrstuvwx')\"; }\nsetopt rcquotes\n", + ), + ( + "ghp_abcdefghijklmnopqrstuvwxyz0123456789", + "alias helper=\"setopt rcquotes; printf '%s' 'ghp_abcdefghijklmno''pqrstuvwxyz0123456789'\"\n", + ), + ( + "ghp_abcdefghijklmnopqrstuvwxyz0123456789", + "alias helper=\"printf '%s' 'ghp_abcdefghijkl'\\\\\n'mnopqrstuvwxyz0123456789'\"\nsetopt rcquotes\n", + ), + ] { + let startup_path = dir.path().join("startup.sh"); + std::fs::write(&startup_path, startup)?; + let output = Command::new("/bin/zsh") + .args(["-fc", &format!(". \"$1\"\n{script}"), "snapshot"]) + .arg(startup_path) + .env_clear() + .env("HOME", dir.path()) + .output()?; + assert!(output.status.success(), "{output:?}"); + let snapshot = captured_script(ShellType::Zsh, &String::from_utf8(output.stdout)?)?; + let path = dir.path().join("snapshot.sh"); + std::fs::write(&path, &snapshot)?; + let replay = Command::new("/bin/zsh") + .args(["-fc", ". \"$1\"; eval helper", "snapshot"]) + .arg(path) + .env_clear() + .output()?; + assert!(replay.status.success(), "{replay:?}"); + assert_eq!(String::from_utf8(replay.stdout)?, real); + assert!( + super::literals::literal_words(&snapshot, ShellType::Zsh) + .expect("valid snapshot") + .contains(&real.to_string()), + "runtime literal was not checked: {snapshot}" + ); + } + Ok(()) +} + +#[test] +fn zsh_snapshot_checks_rcquotes_without_changing_alias_behavior() -> Result<()> { + if !std::path::Path::new("/bin/zsh").exists() { + return Ok(()); + } + let dir = tempdir()?; + let real = "token_ab'cdefghijklmnopqrstuvwx"; + let original = HashMap::from([("VENDOR_PASSWORD".to_string(), real.to_string())]); + let allowed = HashMap::from([( + "VENDOR_PASSWORD".to_string(), + "token_dummy_abcdefghijklmnopqr".to_string(), + )]); + let brokered_keys = vec!["VENDOR_PASSWORD".to_string()]; + let script = snapshot_capture_script(ShellType::Zsh, CAPTURE_NON_INTERACTIVE) + .expect("zsh supports snapshots"); + for (option, option_value) in [("setopt rcquotes", "on"), ("unsetopt rcquotes", "off")] { + for (value, accepted) in [(real, false), ("ordinary'quoted", true)] { + for alias in [ + r#"alias helper="printf '%s' \"$ALIAS_VALUE\"""#, + r#"alias helper="printf '%s' ${(qq)ALIAS_VALUE}""#, + r#"alias helper="printf '%s' ${(qq)ALIAS_VALUE}\"\${EMPTY-}\"""#, + ] { + let output = Command::new("/bin/zsh") + .args([ + "-fc", + &format!("{option}\n{alias}\nunset ALIAS_VALUE\n{script}"), + ]) + .env_clear() + .envs(&original) + .env("HOME", dir.path()) + .env("ALIAS_VALUE", value) + .output()?; + assert!(output.status.success(), "{output:?}"); + let mut snapshot = String::from_utf8(output.stdout)?; + let rewritten = rewrite_snapshot_credentials( + ShellType::Zsh, + &mut snapshot, + SnapshotCredentialEnvironment { + original: &original, + restored: &original, + configured: &HashMap::new(), + discovered: &allowed, + allowed: &allowed, + is_allowed_unset: &|_| false, + brokered_keys: &brokered_keys, + brokered_alias_keys: &[], + allowed_brokered_keys: &brokered_keys, + }, + |text| { + *text = text.replace(real, &allowed["VENDOR_PASSWORD"]); + true + }, + ); + assert_eq!( + rewritten.is_some(), + accepted, + "{option}, {alias}: {snapshot}" + ); + if accepted { + let path = dir.path().join("snapshot.sh"); + std::fs::write(&path, &snapshot)?; + let replay = Command::new("/bin/zsh") + .args([ + "-fc", + ". \"$1\"; eval helper; printf '\\n%s\\n' $options[rcquotes]", + "snapshot", + ]) + .arg(path) + .env_clear() + .envs(&allowed) + .output()?; + assert!(replay.status.success(), "{replay:?}"); + assert_eq!( + String::from_utf8(replay.stdout)?, + format!("{value}\n{option_value}\n") + ); + } + } + } + } + Ok(()) +} + +#[test] +fn brokered_snapshots_check_static_fragments_next_to_expansions() -> Result<()> { + let real = "password$abcdefghijklmno"; + let dir = tempdir()?; + let invocation = dir.path().join("invoke.sh"); + std::fs::write(&invocation, "helper\n")?; + let inner_invocation = dir.path().join("inner-invoke.sh"); + std::fs::write(&inner_invocation, "inner\n")?; + for (shell, aliases) in [("/bin/bash", "alias -p"), ("/bin/zsh", "alias -L")] { + if !std::path::Path::new(shell).exists() { + continue; + } + for (body, accepted) in [ + (r#"printf '%s' "${USER}:password\$abcdefghijklmno""#, false), + (r#"printf '%s' "${USER}:pass"'word$abcdefghijklmno'"#, false), + (r#"printf '%s' "password\$abcdefghijklmno:${USER}""#, false), + ( + r#"printf '%s' "${USER}:"$'password\x24abcdefghijklmno'"#, + false, + ), + (r#"printf '%s' "${USER}:$VENDOR_PASSWORD""#, true), + (r#"printf '%s' "${USER}:ordinary\$value""#, true), + ( + "cat < Result<()> { + let dir = tempdir()?; + let startup = dir.path().join("startup.sh"); + let real = "ghp_abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGH"; + let dummy = "ghp_brokered_dummy_abcdefghijklmnopqrstuvwxyz0123"; + let credential_shaped_function = "ghp_0123456789abcdefghijklmnopqrstuvwxyzABCD"; + std::fs::write( + &startup, + format!( + "helper() {{\n cat <<'EOF'\nexport LEGACY_SETTING=production\nAuthorization: {real}\nEOF\n}}\n{credential_shaped_function}() {{ printf UNEXPECTED_EXECUTION; }}\nexport -f helper\nexport GH_TOKEN='{real}'\n" + ), + )?; + let output = Command::new("/bin/bash") + .arg("-c") + .arg( + snapshot_capture_script(ShellType::Bash, CAPTURE_ALL).expect("bash supports snapshots"), + ) + .env_clear() + .env("HOME", dir.path()) + .env("PATH", "/usr/bin:/bin") + .env("BASH_ENV", &startup) + .output()?; + assert!(output.status.success()); + + let mut snapshot = String::from_utf8(output.stdout)?; + let original = HashMap::from([("GH_TOKEN".to_string(), real.to_string())]); + let discovered = HashMap::from([("GH_TOKEN".to_string(), dummy.to_string())]); + let brokered_keys = vec!["GH_TOKEN".to_string()]; + let snapshot_is_safe = rewrite_snapshot_credentials( + ShellType::Bash, + &mut snapshot, + SnapshotCredentialEnvironment { + original: &original, + restored: &original, + configured: &HashMap::new(), + discovered: &discovered, + allowed: &discovered, + is_allowed_unset: &|_| false, + brokered_keys: &brokered_keys, + brokered_alias_keys: &[], + allowed_brokered_keys: &brokered_keys, + }, + |text| { + if text.contains(credential_shaped_function) { + *text = text.replace(credential_shaped_function, ""); + return false; + } + *text = text.replace(real, dummy); + true + }, + ); + assert!(snapshot_is_safe.is_none()); + assert!(snapshot.contains(real)); + assert!(snapshot.contains(credential_shaped_function)); + + Ok(()) +} + +#[test] +fn brokered_zsh_tied_alias_respects_policy_override() -> Result<()> { + if !std::path::Path::new("/bin/zsh").exists() { + return Ok(()); + } + let real = "ghp_abcdefghijklmnopqrstuvwxyz0123456789"; + let dummy = "ghp_dummy_abcdefghijklmnopqrstuvwxyz0123"; + let keys = vec!["GH_TOKEN".to_string()]; + let script = snapshot_capture_script(ShellType::Zsh, CAPTURE_NON_INTERACTIVE).unwrap(); + for (replacement, source_present, source_allowed) in ["", "replacement|plain|replacement"] + .into_iter() + .flat_map(|replacement| { + [ + (replacement, true, true), + (replacement, true, false), + (replacement, false, false), + ] + }) + { + let unset_source = if source_present { "" } else { "unset GH_TOKEN" }; + let output = Command::new("/bin/zsh") + .args([ + "-fc", + &format!( + "export -UT AUTH_HEADER auth_header=(\"Bearer $GH_TOKEN\" plain) '|'\n{unset_source}\n{script}" + ), + ]) + .env_clear() + .env("GH_TOKEN", real) + .output()?; + assert!(output.status.success(), "{output:?}"); + let mut snapshot = String::from_utf8(output.stdout)?; + let mut original = HashMap::from([ + ("GH_TOKEN".to_string(), real.to_string()), + ("AUTH_HEADER".to_string(), format!("Bearer {real}|plain")), + ]); + let brokered_keys = if source_present { + keys.as_slice() + } else { + original.remove("GH_TOKEN"); + &[] + }; + let discovered = original + .iter() + .map(|(key, value)| (key.clone(), value.replace(real, dummy))) + .collect::>(); + let configured = HashMap::from([("AUTH_HEADER".to_string(), replacement.to_string())]); + let mut allowed = discovered.clone(); + allowed.extend(configured.clone()); + let allowed_keys = if source_allowed { + keys.as_slice() + } else { + allowed.remove("GH_TOKEN"); + &[] + }; + let prepared = rewrite_snapshot_credentials( + ShellType::Zsh, + &mut snapshot, + SnapshotCredentialEnvironment { + original: &original, + restored: &original, + configured: &configured, + discovered: &discovered, + allowed: &allowed, + is_allowed_unset: &|_| true, + brokered_keys, + brokered_alias_keys: &[], + allowed_brokered_keys: allowed_keys, + }, + |value| { + if !source_allowed && (value.contains(real) || value.contains(dummy)) { + return false; + } + *value = value.replace(real, dummy); + true + }, + ) + .expect("policy override keeps the tied alias usable"); + assert_eq!( + prepared.aliases, + if source_present { + configured + } else { + HashMap::new() + } + ); + assert!(!snapshot.contains(real)); + let inspect = "printf '%s\\0' \"$AUTH_HEADER\" \"${auth_header[@]}\"; AUTH_HEADER='next|next|last'; printf '%s\\0' \"$AUTH_HEADER\" \"${auth_header[@]}\""; + let mut outputs = Vec::new(); + for setup in [snapshot.as_str(), "export -UT AUTH_HEADER auth_header '|'"] { + let output = Command::new("/bin/zsh") + .args(["-fc", &format!("set -e; {setup}\n{inspect}")]) + .env_clear() + .env("AUTH_HEADER", replacement) + .output()?; + assert!(output.status.success(), "{output:?}"); + outputs.push(output.stdout); + } + assert_eq!(outputs[0], outputs[1]); + } + Ok(()) +} + #[test] fn brokered_snapshot_alias_values_follow_allowed_credential_overrides() { let real = "ghp_abcdefghijklmnopqrstuvwxyz0123456789"; @@ -570,6 +1895,30 @@ fn brokered_snapshot_alias_values_follow_allowed_credential_overrides() { } } +#[test] +fn brokered_snapshot_rejects_shell_escaped_credentials() { + let real = "token_ab'cdefgh"; + let dummy = "token_xy'zabcde"; + let mut snapshot = format!( + "# Snapshot file\n# aliases 1\nalias helper='tool {escaped}'\n# exports 0\n", + escaped = real.replace('\'', r#"'\''"#), + ); + let original = snapshot.clone(); + + let snapshot_is_safe = rewrite_snapshot_credentials( + ShellType::Bash, + &mut snapshot, + empty_snapshot_credentials(&HashMap::new(), &|_| false), + |text| { + *text = text.replace(real, dummy); + true + }, + ); + + assert!(snapshot_is_safe.is_none()); + assert_eq!(snapshot, original); +} + #[cfg(target_os = "macos")] #[test] fn zsh_snapshot_restores_tied_path() -> Result<()> { @@ -698,85 +2047,3 @@ fn captured_script(shell_type: ShellType, source: &str) -> Result { CapturedSnapshot::parse(shell_type, source.as_bytes()).context("invalid native capture")?; Ok(captured.render_script()) } - -#[test] -fn brokered_bash_snapshot_preserves_exported_functions_and_redacts_heredocs() -> Result<()> { - let dir = tempdir()?; - let startup = dir.path().join("startup.sh"); - let real = "ghp_abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGH"; - let dummy = "ghp_brokered_dummy_abcdefghijklmnopqrstuvwxyz0123"; - let credential_shaped_function = "ghp_0123456789abcdefghijklmnopqrstuvwxyzABCD"; - std::fs::write( - &startup, - format!( - "helper() {{\n cat <<'EOF'\nexport LEGACY_SETTING=production\nAuthorization: {real}\nEOF\n}}\n{credential_shaped_function}() {{ printf UNEXPECTED_EXECUTION; }}\nexport -f helper\nexport GH_TOKEN='{real}'\n" - ), - )?; - let output = Command::new("/bin/bash") - .arg("-c") - .arg( - snapshot_capture_script( - ShellType::Bash, - SnapshotCaptureOptions { - startup: SnapshotStartup::NonInteractive, - ..CAPTURE_ALL - }, - ) - .expect("bash supports snapshots"), - ) - .env_clear() - .env("HOME", dir.path()) - .env("PATH", "/usr/bin:/bin") - .env("BASH_ENV", &startup) - .output()?; - assert!(output.status.success()); - - let mut snapshot = String::from_utf8(output.stdout)?; - let original = HashMap::from([("GH_TOKEN".to_string(), real.to_string())]); - let discovered = HashMap::from([("GH_TOKEN".to_string(), dummy.to_string())]); - let brokered_keys = vec!["GH_TOKEN".to_string()]; - rewrite_snapshot_credentials( - ShellType::Bash, - &mut snapshot, - SnapshotCredentialEnvironment { - original: &original, - restored: &original, - configured: &HashMap::new(), - discovered: &discovered, - allowed: &discovered, - is_allowed_unset: &|_| false, - brokered_keys: &brokered_keys, - brokered_alias_keys: &[], - allowed_brokered_keys: &brokered_keys, - }, - |text| { - if text.contains(credential_shaped_function) { - *text = text.replace(credential_shaped_function, ""); - return false; - } - *text = text.replace(real, dummy); - true - }, - ); - assert!(!snapshot.contains(real)); - assert!(snapshot.contains(credential_shaped_function)); - let path = dir.path().join("snapshot.sh"); - std::fs::write(&path, snapshot)?; - - let restored = Command::new("/bin/bash") - .arg("-c") - .arg(". \"$1\"; exec /bin/bash -c helper") - .arg("snapshot") - .arg(&path) - .env_clear() - .env("PATH", "/usr/bin:/bin") - .env("GH_TOKEN", dummy) - .output()?; - assert!(restored.status.success()); - assert_eq!( - String::from_utf8(restored.stdout)?, - format!("export LEGACY_SETTING=production\nAuthorization: {dummy}\n") - ); - - Ok(()) -} diff --git a/codex-rs/shell-escalation/src/unix/escalate_server.rs b/codex-rs/shell-escalation/src/unix/escalate_server.rs index f45c4317e6..139450dc3d 100644 --- a/codex-rs/shell-escalation/src/unix/escalate_server.rs +++ b/codex-rs/shell-escalation/src/unix/escalate_server.rs @@ -336,6 +336,7 @@ async fn handle_escalate_session_with_policy( command .args(args) .arg0(arg0.unwrap_or_else(|| program.clone())) + .env_clear() .envs(&env) .current_dir(&cwd) .stdin(Stdio::null())