mirror of
https://github.com/openai/codex.git
synced 2026-10-20 00:18:49 +00:00
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:
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -197,7 +197,7 @@ impl ShellSnapshotSandbox {
|
||||
request,
|
||||
ExecOptions {
|
||||
expiration,
|
||||
capture_policy: ExecCapturePolicy::FullBufferWithExpiration,
|
||||
capture_policy: ExecCapturePolicy::SensitiveFullBuffer,
|
||||
},
|
||||
workspace_roots,
|
||||
)
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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(©_key, "dummy-openai-key")
|
||||
.output()
|
||||
.expect("run unset credential probe");
|
||||
assert!(
|
||||
unset_output.status.success(),
|
||||
"unset credential probe failed: {unset_output:?}"
|
||||
);
|
||||
assert_eq!(String::from_utf8_lossy(&unset_output.stdout), "unset|unset");
|
||||
|
||||
let empty_output = Command::new("/bin/bash")
|
||||
.args([
|
||||
"-c",
|
||||
&format!(
|
||||
"OPENAI_API_KEY=\n{restore}\nprintf '%s|%s' \"$OPENAI_API_KEY\" \"${{{copy_key}-unset}}\""
|
||||
),
|
||||
])
|
||||
.env(©_key, "dummy-openai-key")
|
||||
.output()
|
||||
.expect("run empty credential probe");
|
||||
assert!(
|
||||
empty_output.status.success(),
|
||||
"empty credential probe failed: {empty_output:?}"
|
||||
);
|
||||
assert_eq!(String::from_utf8_lossy(&empty_output.stdout), "|unset");
|
||||
|
||||
let unchanged_output = Command::new("/bin/bash")
|
||||
.args([
|
||||
"-c",
|
||||
&format!(
|
||||
"readonly OPENAI_API_KEY=dummy-openai-key\n{restore}\nprintf '%s|%s' \"$OPENAI_API_KEY\" \"${{{copy_key}-unset}}\""
|
||||
),
|
||||
])
|
||||
.env(©_key, "dummy-openai-key")
|
||||
.output()
|
||||
.expect("run unchanged readonly credential probe");
|
||||
assert!(
|
||||
unchanged_output.status.success(),
|
||||
"unchanged readonly credential probe failed: {unchanged_output:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&unchanged_output.stdout),
|
||||
"dummy-openai-key|unset"
|
||||
);
|
||||
|
||||
let xtrace_output = Command::new("/bin/bash")
|
||||
.args([
|
||||
"-c",
|
||||
&format!(
|
||||
"OPENAI_API_KEY=real-openai-key\nset -x\n{restore}\nset +x\nprintf '%s|%s' \"$OPENAI_API_KEY\" \"${{{copy_key}-unset}}\""
|
||||
),
|
||||
])
|
||||
.env(©_key, "dummy-openai-key")
|
||||
.output()
|
||||
.expect("run xtrace credential probe");
|
||||
assert!(
|
||||
xtrace_output.status.success(),
|
||||
"xtrace credential probe failed: {xtrace_output:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&xtrace_output.stdout),
|
||||
"dummy-openai-key|unset"
|
||||
);
|
||||
assert!(!String::from_utf8_lossy(&xtrace_output.stderr).contains("real-openai-key"));
|
||||
|
||||
let unset_marker_key = format!("{SNAPSHOT_BROKERED_UNSET_ENV_PREFIX}OPENAI_API_KEY");
|
||||
let unset_env = HashMap::from([(unset_marker_key.clone(), "1".to_string())]);
|
||||
let unset_restore = build_brokered_credential_exports(&unset_env, /*remove_copies*/ true);
|
||||
let readonly_empty_output = Command::new("/bin/bash")
|
||||
.args([
|
||||
"-c",
|
||||
&format!(
|
||||
"readonly OPENAI_API_KEY=\n{unset_restore}\nprintf '%s|%s' \"$OPENAI_API_KEY\" \"${{{unset_marker_key}-unset}}\""
|
||||
),
|
||||
])
|
||||
.env(&unset_marker_key, "1")
|
||||
.output()
|
||||
.expect("run readonly empty credential probe");
|
||||
assert!(
|
||||
readonly_empty_output.status.success(),
|
||||
"readonly empty credential probe failed: {readonly_empty_output:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&readonly_empty_output.stdout),
|
||||
"|unset"
|
||||
);
|
||||
|
||||
let output = Command::new("/bin/bash")
|
||||
.args([
|
||||
"-c",
|
||||
&format!("readonly OPENAI_API_KEY=real-openai-key\n{restore}\nprintf should-not-run"),
|
||||
])
|
||||
.env(copy_key, "dummy-openai-key")
|
||||
.output()
|
||||
.expect("run readonly credential probe");
|
||||
|
||||
assert!(!output.status.success(), "command unexpectedly succeeded");
|
||||
assert!(
|
||||
output.stdout.is_empty(),
|
||||
"protected command unexpectedly ran"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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")]);
|
||||
|
||||
@@ -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 "ing in Quoting::deferred_modes(shell_type) {
|
||||
if checked_aliases.insert((body.clone(), quoting)) {
|
||||
words.extend(literal_words_with_quoting(
|
||||
&body,
|
||||
shell_type,
|
||||
Parsing::Deferred(quoting),
|
||||
checked_aliases,
|
||||
)?);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Deferred bodies may be parsed after the snapshot restores RC_QUOTES.
|
||||
let mut cursor = node.walk();
|
||||
for child in node.named_children(&mut cursor) {
|
||||
pending.extend(
|
||||
Quoting::deferred_modes(shell_type)
|
||||
.iter()
|
||||
.map(|"ing| (child, quoting)),
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if node.kind() == "heredoc_body" {
|
||||
let parent = node.parent()?;
|
||||
let mut cursor = parent.walk();
|
||||
let delimiter = parent
|
||||
.named_children(&mut cursor)
|
||||
.find(|child| child.kind() == "heredoc_start")?
|
||||
.utf8_text(script.as_bytes())
|
||||
.ok()?;
|
||||
let quoted = delimiter.contains(['\'', '"', '\\']);
|
||||
let strip_tabs = parent
|
||||
.children(&mut cursor)
|
||||
.any(|child| child.kind() == "<<-");
|
||||
let mut start = node.start_byte();
|
||||
let mut cursor = node.walk();
|
||||
// Decode static heredoc text without joining across an expansion.
|
||||
for end in node
|
||||
.named_children(&mut cursor)
|
||||
.filter(|child| !quoted && child.kind() != "heredoc_content")
|
||||
.map(Some)
|
||||
.chain(std::iter::once(None))
|
||||
{
|
||||
let text = &script[start..end.map_or(node.end_byte(), |child| child.start_byte())];
|
||||
let text = if strip_tabs {
|
||||
text.split_inclusive('\n')
|
||||
.map(|line| line.trim_start_matches('\t'))
|
||||
.collect::<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 "ing in Quoting::deferred_modes(shell_type) {
|
||||
if checked_aliases.insert((body.clone(), quoting)) {
|
||||
words.extend(literal_words_with_quoting(
|
||||
&body,
|
||||
shell_type,
|
||||
Parsing::Deferred(quoting),
|
||||
checked_aliases,
|
||||
)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut pieces = vec![node];
|
||||
if shell_type == ShellType::Zsh
|
||||
&& node.prev_named_sibling().is_none_or(|previous| {
|
||||
!has_continuation_gap(script, previous.end_byte(), node.start_byte())
|
||||
})
|
||||
{
|
||||
// Tree-sitter splits unquoted continuations into sibling words. Decode the
|
||||
// pieces separately: removing the gap would change adjacent RC_QUOTES strings.
|
||||
let mut previous = node;
|
||||
while let Some(next) = previous.next_named_sibling() {
|
||||
if !has_continuation_gap(script, previous.end_byte(), next.start_byte()) {
|
||||
break;
|
||||
}
|
||||
pieces.push(next);
|
||||
previous = next;
|
||||
}
|
||||
}
|
||||
let continued = pieces.len() > 1;
|
||||
if node.kind() == "ansi_c_string" {
|
||||
words.push(decode_ansi_c(
|
||||
node.utf8_text(script.as_bytes()).ok()?,
|
||||
shell_type,
|
||||
quoting,
|
||||
)?);
|
||||
if !continued {
|
||||
continue;
|
||||
}
|
||||
} else if let Some(word) = literal_word(node, script, shell_type, quoting) {
|
||||
words.push(word);
|
||||
if !continued {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if continued || matches!(node.kind(), "string" | "concatenation") {
|
||||
// Join adjacent static pieces, but never join across an expansion.
|
||||
pieces.reverse();
|
||||
let mut literal = Vec::new();
|
||||
let mut raw_string_end = None;
|
||||
while let Some(piece) = pieces.pop() {
|
||||
if let Some(word) = literal_word_bytes(piece, script, shell_type, quoting) {
|
||||
if matches!(quoting, Quoting::ZshRcQuotes)
|
||||
&& piece.kind() == "raw_string"
|
||||
&& raw_string_end == Some(piece.start_byte())
|
||||
{
|
||||
literal.push(b'\'');
|
||||
}
|
||||
literal.extend(word);
|
||||
raw_string_end = (piece.kind() == "raw_string").then_some(piece.end_byte());
|
||||
} else if piece.kind() == "variable_assignment" {
|
||||
if !literal.is_empty() {
|
||||
words.push(String::from_utf8_lossy(&literal).into_owned());
|
||||
literal.clear();
|
||||
}
|
||||
// Only the value can continue into the next word, never the assignment name.
|
||||
pieces.extend(piece.child_by_field_name("value"));
|
||||
} else if piece.kind() == "string_content" {
|
||||
let content = piece.utf8_text(script.as_bytes()).ok()?;
|
||||
let decoded = shlex::split(&format!("\"{content}\""))?;
|
||||
let [word] = decoded.as_slice() else {
|
||||
return None;
|
||||
};
|
||||
literal.extend_from_slice(word.as_bytes());
|
||||
} else if matches!(piece.kind(), "string" | "concatenation" | "command_name") {
|
||||
if shell_type == ShellType::Zsh
|
||||
&& piece.prev_sibling().is_some_and(|previous| {
|
||||
previous.kind() == "$" && previous.end_byte() == piece.start_byte()
|
||||
})
|
||||
{
|
||||
literal.push(b'$');
|
||||
}
|
||||
pieces.extend(
|
||||
(0..piece.named_child_count())
|
||||
.rev()
|
||||
.filter_map(|index| piece.named_child(index)),
|
||||
);
|
||||
} else if !literal.is_empty() {
|
||||
words.push(String::from_utf8_lossy(&literal).into_owned());
|
||||
literal.clear();
|
||||
}
|
||||
}
|
||||
if !literal.is_empty() {
|
||||
words.push(String::from_utf8_lossy(&literal).into_owned());
|
||||
}
|
||||
}
|
||||
let mut cursor = node.walk();
|
||||
pending.extend(
|
||||
node.named_children(&mut cursor)
|
||||
.map(|child| (child, quoting)),
|
||||
);
|
||||
}
|
||||
Some(words)
|
||||
}
|
||||
|
||||
fn has_continuation_gap(script: &str, left_end: usize, right_start: usize) -> bool {
|
||||
let gap = &script[left_end..right_start];
|
||||
!gap.is_empty() && gap.as_bytes().chunks(2).all(|pair| pair == b"\\\n")
|
||||
}
|
||||
|
||||
fn literal_word(
|
||||
node: Node<'_>,
|
||||
script: &str,
|
||||
shell_type: ShellType,
|
||||
quoting: Quoting,
|
||||
) -> Option<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())
|
||||
|
||||
Reference in New Issue
Block a user