Harden credential handling in shell snapshots and replay (#44040)

## Why

Shell quoting can hide credentials from raw-text checks, and startup files can restore real credentials after the broker replaces them with dummy values.

## What changed

- Decode shell literals without evaluating them and reject snapshots containing credentials in executable source, including aliases, functions, and heredocs.
- Preserve credential policy overrides, explicit unsets, and aliases whose source variables were removed. Support credential aliases in Zsh tied arrays while rejecting credentials that span array elements.
- Guard snapshot replay against credential restoration through shell startup files and preserve unrelated `ENV` settings.
- Apply Windows environment-key casing rules to credential overrides, suppress unredacted sandbox diagnostics during snapshot capture, and clear inherited environment variables before launching escalated commands.

## Testing

Add regression coverage for shell quoting and escaped credentials, Zsh tied arrays, startup-file replay, readonly credentials, policy overrides, and sensitive capture timeout and cancellation handling.

GitOrigin-RevId: 58274c07c715423241d26ce6dd2c2b4230cf0f64
This commit is contained in:
Winston Howes
2026-09-09 04:00:10 +00:00
committed by copyberry
parent a548463b78
commit ec512d2347
21 changed files with 4215 additions and 256 deletions
+6 -2
View File
@@ -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");
+5 -5
View File
@@ -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)
});
+13 -5
View File
@@ -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<usize> {
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<RawExecToolCallOutput, CodexErr>,
sandbox_type: SandboxType,
duration: Duration,
capture_policy: ExecCapturePolicy,
) -> Result<ExecToolCallOutput> {
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 {
+11 -7
View File
@@ -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 {
+304 -50
View File
@@ -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<String>,
credential_env: HashMap<String, String>,
unset_credential_keys: Vec<String>,
// Retain discovered key identity even when policy replaces or removes its value.
startup_credential_keys: Vec<String>,
context_env: HashMap<String, String>,
fail_open_aliases: HashMap<String, FailOpenAlias>,
context_credential_keys: HashMap<String, Vec<String>>,
binding_context_credential_keys: HashMap<String, Vec<String>>,
}
@@ -92,6 +100,12 @@ struct SnapshotCredentialBroker {
allow_login_shell: bool,
}
struct FailOpenAlias {
value: String,
dummy_value: Option<String>,
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<String, String>,
@@ -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<String, String>) {
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::<HashMap<_, _>>();
// 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::<Vec<_>>();
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<String, String> = 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::<HashMap<_, _>>();
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<String, FailOpenAlias> = 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::<String, Vec<String>>::new();
let mut binding_context_credential_keys = HashMap::<String, Vec<String>>::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<Item = &'static str>,
) {
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<String, String>, key: &str) -> bool {
env_value(env, key).is_some()
}
fn env_value<'a>(env: &'a HashMap<String, String>, 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<String, String>, 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<String, String>, 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);
+1 -1
View File
@@ -197,7 +197,7 @@ impl ShellSnapshotSandbox {
request,
ExecOptions {
expiration,
capture_policy: ExecCapturePolicy::FullBufferWithExpiration,
capture_policy: ExecCapturePolicy::SensitiveFullBuffer,
},
workspace_roots,
)
+383 -23
View File
@@ -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<String> {
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(())
}
+228 -6
View File
@@ -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<String, String>,
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<String, String>,
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<String, String>, 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::<Vec<_>>();
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::<Vec<_>>()
.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::<Vec<_>>();
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::<Vec<_>>()
.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<String, String>,
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::<Vec<_>>()
@@ -617,7 +840,6 @@ mod prepare_powershell_command_tests {
assert_eq!(rewritten, command);
}
#[test]
fn local_elevated_powershell_uses_discovered_fallback() {
let command = vec![
+581 -8
View File
@@ -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<String, String>,
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<String, String>| {
wrap_brokered_snapshot(&posix_shell, snapshot, env, &no_prepends)
};
let nested = wrap_snapshot(&snapshot, &env);
let run_nested =
|command: &[String], env: &HashMap<String, String>| -> anyhow::Result<String> {
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<String, String>| {
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(&copy_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(&copy_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(&copy_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(&copy_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"
);
}
@@ -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<UnifiedExecRequest, UnifiedExecAttempt> 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<UnifiedExecRequest, UnifiedExecAttempt> 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
{
+170 -9
View File
@@ -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::<Vec<_>>();
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::<Vec<_>>();
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<()> {
+101 -11
View File
@@ -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<String, String>,
) {
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::<Vec<_>>();
let brokered_dummy_values = brokered
.iter()
.map(|(_, dummy_value)| dummy_value.clone())
.collect::<Vec<_>>();
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<String, String>) -> 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<String, String>) -> Vec<String> {
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::<Vec<(String, String)>>(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::<Vec<_>>();
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::<Vec<_>>();
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<String, String>) -> Vec<String> {
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::<Vec<(String, String)>>(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::<Vec<_>>();
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::<Vec<_>>();
keys.sort_unstable();
keys
}
/// Returns environment keys used to bind currently brokered credentials to hosts.
pub fn brokered_credential_binding_env_keys(
env: &HashMap<String, String>,
@@ -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")]);
+2
View File
@@ -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;
@@ -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;
@@ -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<CapturedStartupEnvironment<'a>>,
pub(super) shell_type: ShellType,
pub(super) state: &'a str,
pub(super) aliases: &'a str,
pub(super) exports: Vec<CapturedExport<'a>>,
@@ -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<usize>,
pub values: Vec<Vec<u8>>,
pub separator: Vec<u8>,
}
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::<Option<Vec<_>>>();
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 });
}
}
}
@@ -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::<Option<Vec<_>>>()
.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::<Vec<_>>()
.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::<Vec<_>>();
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::<Vec<_>>();
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,
})
}
@@ -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<Vec<String>> {
// 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<Vec<String>> {
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 &quoting 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(|&quoting| (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::<String>()
} 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<usize>, Option<Vec<u8>>)> = 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::<Vec<_>>();
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 &quoting 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<String> {
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<Vec<u8>> {
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<String> {
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<Vec<u8>> {
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<MetaOrder>,
}
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
}
}
@@ -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<Value>,
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::<Option<Vec<_>>>()?;
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)
File diff suppressed because it is too large Load Diff
@@ -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())