Support remote credential masking with Zsh shell snapshots (#54432)

## Why

Remote credential masking previously supported only Bash commands without login startup or shell snapshots, leaving Zsh commands with unmasked credentials.

## What changed

- Enable masking for Zsh login commands using shell snapshots on Linux and macOS.
- Capture trusted startup profiles per launch, bind the credential broker to the resulting environment, and replace credentials and supported aliases with placeholders before snapshot replay.
- Preserve explicit environment overrides, credential unsets, and launcher-managed proxy settings during replay.
- Warn and fall back to unmasked startup when a snapshot cannot safely replay credentials; stop execution if protected snapshot restoration fails.

## Testing

Extend remote execution tests for inherited and profile-defined credentials, overrides, destination changes, and unsupported-profile fallback. Add a Zsh scenario snapshot showing a credential placeholder in command output.

GitOrigin-RevId: 83193d6cbc05f3deb0d6d0b9ae80974b353ce900
This commit is contained in:
viyatb-oai
2026-10-15 22:25:58 +00:00
committed by copyberry
parent aff92080f2
commit 1c5ca2a979
18 changed files with 895 additions and 177 deletions
@@ -10,6 +10,8 @@ mod exports;
mod literals;
#[path = "shell_snapshot_render.rs"]
mod render;
#[path = "shell_snapshot_restore.rs"]
mod restore;
pub use capture::CapturedSnapshot;
pub use capture::CapturedStartupEnvironment;
@@ -19,6 +21,9 @@ pub use capture::snapshot_source_capture_script;
pub use credentials::PreparedSnapshot;
pub use credentials::SnapshotCredentialEnvironment;
pub use credentials::prepare_snapshot_credentials;
pub use restore::SNAPSHOT_BROKERED_UNSET_ENV_PREFIX;
pub use restore::SNAPSHOT_BROKERED_VALUE_ENV_PREFIX;
pub use restore::build_brokered_credential_exports;
use capture::BASH_SH_SNAPSHOT_HEADER;
@@ -8,6 +8,7 @@ use super::render;
use super::render::Export;
use super::render::Value;
use super::render::ValuePart;
use crate::shell_detect::ShellType;
use std::collections::HashMap;
/// Environment views used to render protected credential references into a shell snapshot.
@@ -106,7 +107,9 @@ pub fn prepare_snapshot_credentials(
let next_credential = credential_keys
.iter()
.filter_map(|credential_key| {
let dummy = discovered.get(*credential_key)?;
let dummy = discovered
.get(*credential_key)
.filter(|dummy| !dummy.is_empty())?;
let real = real_credential_value(credential_key)?;
let allowed_key = allowed_brokered_keys
.iter()
@@ -296,11 +299,24 @@ pub fn prepare_snapshot_credentials(
return Some(Export::Captured(line));
}
return Some(Export::Assignment {
declaration,
declaration: if captured.shell_type == ShellType::Zsh
&& configured.contains_key(key)
{
format!("export {key}").into()
} else {
declaration.into()
},
value: assignment,
});
}
// Explicit scalar overrides must not inherit captured coercion attributes.
if captured.shell_type == ShellType::Zsh
&& configured.contains_key(key)
&& matches!(export.value, ExportValue::Plain)
{
return None;
}
Some(Export::Captured(line))
})
.collect::<Vec<_>>();
@@ -4,6 +4,7 @@
use super::capture::CapturedSnapshot;
use codex_protocol::config_types::ShellEnvironmentPolicy;
use codex_protocol::shell_environment::create_env_from_vars;
use std::borrow::Cow;
impl CapturedSnapshot<'_> {
/// Render native functions, options, and aliases; the executor restores environment separately.
@@ -45,7 +46,7 @@ pub(super) enum ValuePart {
pub(super) enum Export<'a> {
Captured(&'a str),
Assignment {
declaration: &'a str,
declaration: Cow<'a, str>,
value: Value,
},
Array {
@@ -0,0 +1,80 @@
//! Restore brokered values after shell startup and snapshot replay.
use std::collections::HashMap;
pub const SNAPSHOT_BROKERED_VALUE_ENV_PREFIX: &str = "CODEX_NETWORK_PROXY_SNAPSHOT_BROKERED_VALUE_";
pub const SNAPSHOT_BROKERED_UNSET_ENV_PREFIX: &str = "CODEX_NETWORK_PROXY_SNAPSHOT_BROKERED_UNSET_";
/// Restore protected credential copies and unsets after shell startup.
/// Keep the copies for another replay stage, or remove them before the user command.
pub 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 = [value_exports, unset_exports]
.into_iter()
.filter(|block| !block.is_empty())
.collect::<Vec<_>>()
.join("\n");
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 is_valid_shell_variable_name(name: &str) -> bool {
let mut chars = name.chars();
let Some(first) = chars.next() else {
return false;
};
if !(first == '_' || first.is_ascii_alphabetic()) {
return false;
}
chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
}