Protect shell snapshots when credential brokerage is enabled (#43909)

## Why

Shell startup files can introduce credentials or overwrite brokered dummy values. Snapshot capture and replay need to respect the command's sandbox and environment policy without persisting real credentials.

## What changed

- Capture and validate brokered snapshots per command using its requested shell, working directory, login mode, sandbox, and environment policy.
- Discover shell-initialized credentials and prepare snapshot exports for brokered replay. Preserve shell functions and aliases while preventing startup files from overwriting dummy credentials during replay.
- Rebuild snapshots as credential broker state changes and retain each command's snapshot for the lifetime of its process.
- Reject brokered POSIX shell execution when a protected snapshot cannot be created, and reject brokerage with `shell_zsh_fork`. Restore real credentials and remove brokerage markers for unsupported PowerShell and Cmd execution; escalated commands bypass protected snapshots.

## Testing

Add regression coverage for credential discovery and redaction, environment filtering, readonly exports, shell function and heredoc replay, command working directories and login modes, sandbox enforcement, and missing or disabled snapshots.

GitOrigin-RevId: 685356cb9e80f11cf69d053e4d239ee6ccf43a9e
This commit is contained in:
Winston Howes
2026-09-08 20:59:50 +00:00
committed by copyberry
parent 1530f828cb
commit 2e220af1f6
25 changed files with 2080 additions and 133 deletions
@@ -80,6 +80,10 @@ impl NetworkProxySpec {
self.config.enabled
}
pub(crate) fn credential_broker_enabled(&self) -> bool {
self.config.credential_broker && self.constraints.enabled != Some(false)
}
pub fn proxy_host_and_port(&self) -> String {
host_and_port_from_network_addr(&self.config.proxy_url, /*default_port*/ 3128)
}
+99 -10
View File
@@ -15,6 +15,7 @@ use codex_exec_server::ExecutorFileSystem;
use codex_exec_server::SelectedCapabilityRootsStatus;
use codex_protocol::capabilities::CapabilityRootLocation;
use codex_protocol::capabilities::SelectedCapabilityRoot;
use codex_protocol::config_types::ShellEnvironmentPolicy;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EnvironmentConfig;
@@ -37,6 +38,7 @@ use crate::session::turn_context::ShellSnapshotTask;
use crate::session::turn_context::TurnEnvironment;
use crate::shell::Shell;
use crate::shell_snapshot::ShellSnapshot;
use crate::shell_snapshot::SnapshotCredentialBrokerState;
/// Records whether a normalized config should follow later thread setting updates.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -142,6 +144,7 @@ struct ResolvedEnvironment {
executor_platform_os: Option<String>,
temporary_directories: Option<Vec<PathUri>>,
shell_snapshot: ShellSnapshotTask,
shell_snapshot_builder: ShellSnapshot,
shell_snapshot_v2_supported: bool,
installed_config: Option<EnvironmentConfig>,
}
@@ -256,6 +259,13 @@ impl ThreadEnvironments {
let selection = environment.selection;
let config_origin = environment.config_origin;
let selected_environment = Arc::clone(&environment.environment);
let inherited_snapshot = if !selected_environment.is_remote()
&& shell_snapshot.should_rebuild_inherited()
{
futures::future::ready(None).boxed().shared()
} else {
environment.shell_snapshot
};
let resolution: TurnEnvironmentResolution =
futures::future::ready(Ok(ResolvedEnvironment {
environment: environment.environment,
@@ -263,8 +273,11 @@ impl ThreadEnvironments {
user_home_dir: environment.user_home_dir,
executor_platform_os: environment.executor_platform_os,
temporary_directories: environment.temporary_directories,
shell_snapshot: environment.shell_snapshot,
shell_snapshot_v2_supported: environment.shell_snapshot_v2_supported,
shell_snapshot: inherited_snapshot,
shell_snapshot_builder: shell_snapshot.clone(),
shell_snapshot_v2_supported: environment.shell_snapshot_v2_supported
&& (selected_environment.is_remote()
|| !shell_snapshot.should_rebuild_inherited()),
installed_config: None,
}))
.boxed()
@@ -292,6 +305,36 @@ impl ThreadEnvironments {
}
}
fn start_shell_snapshot_task(
shell_snapshot: ShellSnapshot,
environment: Arc<Environment>,
cwd: PathUri,
shell: Option<Shell>,
) -> ShellSnapshotTask {
// Protected snapshots are captured by the command, using its sandbox.
if shell_snapshot.should_rebuild_inherited() {
return futures::future::ready(None).boxed().shared();
}
let shell_snapshot = shell_snapshot
.build(
environment,
cwd,
shell,
/*allow_login_shell*/ true,
ShellEnvironmentPolicy::default(),
/*sandbox*/ None,
)
.boxed()
.shared();
drop(tokio::spawn(
shell_snapshot
.clone()
.in_current_span()
.with_current_subscriber(),
));
shell_snapshot
}
pub(crate) fn update_selections(
&self,
environments: &[TurnEnvironmentSelection],
@@ -482,6 +525,47 @@ impl ThreadEnvironments {
self.environments.store(Arc::new(environments));
}
pub(crate) fn set_snapshot_credential_broker(&self, state: SnapshotCredentialBrokerState) {
if !self.shell_snapshot.set_credential_broker(state) {
return;
}
let mut environments = Vec::clone(&self.environments.load());
let mut changed = false;
for selected in &mut environments {
if !selected.environment.is_remote()
&& let Some(Ok(resolved)) = selected.resolution.clone().now_or_never()
{
self.restart_shell_snapshot(selected, resolved);
changed = true;
}
}
if changed {
self.environments.store(Arc::new(environments));
}
}
fn restart_shell_snapshot(
&self,
selected: &mut SelectedTurnEnvironment,
resolved: ResolvedEnvironment,
) {
let shell_snapshot = Self::start_shell_snapshot_task(
self.shell_snapshot.clone(),
Arc::clone(&resolved.environment),
selected.selection.cwd.clone(),
resolved.shell.clone(),
);
selected.resolution = futures::future::ready(Ok(ResolvedEnvironment {
shell_snapshot,
shell_snapshot_v2_supported: cfg!(unix)
&& !self.shell_snapshot.should_rebuild_inherited(),
..resolved
}))
.boxed()
.shared();
}
/// Combines persisted thread roots with installed attachment roots, keeping
/// thread roots first and hiding attachments that are not ready yet.
pub(crate) fn inspect_selected_capability_roots(
@@ -667,13 +751,15 @@ impl ThreadEnvironments {
cfg!(unix),
)
};
let task = shell_snapshot
.build(Arc::clone(&environment), selection.cwd, shell.clone())
.boxed()
.shared();
drop(tokio::spawn(
task.clone().in_current_span().with_current_subscriber(),
));
let shell_snapshot_builder = shell_snapshot.clone();
let task = Self::start_shell_snapshot_task(
shell_snapshot,
Arc::clone(&environment),
selection.cwd,
shell.clone(),
);
let shell_snapshot_v2_supported = snapshot_v2
&& (environment.is_remote() || !shell_snapshot_builder.should_rebuild_inherited());
Ok(ResolvedEnvironment {
environment,
shell,
@@ -681,7 +767,8 @@ impl ThreadEnvironments {
executor_platform_os,
temporary_directories: temporary_dirs,
shell_snapshot: task,
shell_snapshot_v2_supported: snapshot_v2,
shell_snapshot_builder,
shell_snapshot_v2_supported,
installed_config,
})
}
@@ -762,6 +849,8 @@ impl TurnEnvironmentState {
);
turn_environment.executor_platform_os = environment.executor_platform_os;
turn_environment.shell_snapshot = environment.shell_snapshot;
turn_environment.shell_snapshot_builder =
Some(Box::new(environment.shell_snapshot_builder));
turn_environment.shell_snapshot_v2_supported =
environment.shell_snapshot_v2_supported;
turn_environment.user_home_dir = environment.user_home_dir;
+29
View File
@@ -46,6 +46,7 @@ use crate::session::step_settings::ResolvedStepSettings;
use crate::session::step_settings::StepSettings;
use crate::session::turn_context::TurnEnvironment;
use crate::session_prefix::format_inter_agent_completion_message;
use crate::shell_snapshot::SnapshotCredentialBrokerState;
use crate::skills_load_input_from_config;
use crate::stream_events_utils::mark_thread_memory_mode_polluted_if_external_context;
use crate::turn_metadata::TurnMetadataState;
@@ -1165,6 +1166,9 @@ impl Session {
.cloned()
else {
self.services.network_proxy.store(None);
self.services
.turn_environments
.set_snapshot_credential_broker(SnapshotCredentialBrokerState::Inactive);
return;
};
@@ -1191,11 +1195,22 @@ impl Session {
// listeners and must not be exposed as active managed proxy runtimes.
if !spec.enabled() {
self.services.network_proxy.store(None);
self.services
.turn_environments
.set_snapshot_credential_broker(SnapshotCredentialBrokerState::Inactive);
return;
}
if let Some(started_proxy) = self.services.network_proxy.load_full() {
if let Err(err) = spec.apply_to_started_proxy(started_proxy.as_ref()).await {
warn!("failed to refresh managed network proxy for sandbox change: {err}");
} else {
self.services
.turn_environments
.set_snapshot_credential_broker(if spec.credential_broker_enabled() {
SnapshotCredentialBrokerState::Ready(started_proxy.proxy())
} else {
SnapshotCredentialBrokerState::Inactive
});
}
return;
}
@@ -1214,11 +1229,25 @@ impl Session {
.await
{
Ok((started_proxy, _session_network_proxy)) => {
if spec.credential_broker_enabled() {
self.services
.turn_environments
.set_snapshot_credential_broker(SnapshotCredentialBrokerState::Ready(
started_proxy.proxy(),
));
}
self.services
.network_proxy
.store(Some(Arc::new(started_proxy)));
}
Err(err) => {
self.services
.turn_environments
.set_snapshot_credential_broker(if spec.credential_broker_enabled() {
SnapshotCredentialBrokerState::Unavailable
} else {
SnapshotCredentialBrokerState::Inactive
});
warn!("failed to start managed network proxy for sandbox change: {err}");
}
}
+48 -7
View File
@@ -14,6 +14,7 @@ use crate::hook_mcp_executor::CoreHookMcpExecutor;
use crate::responses_metadata::CodexResponsesMetadata;
use crate::responses_metadata::CodexResponsesRequestKind;
use crate::shell_snapshot::ShellSnapshot;
use crate::shell_snapshot::SnapshotCredentialBrokerState;
use crate::state::ActiveTurn;
use codex_extension_api::ExtensionDataInit;
use codex_http_client::ClientRouteClass;
@@ -1091,13 +1092,10 @@ impl Session {
}),
});
}
let effective_config = config.config_layer_stack.effective_config();
let config_path = config.codex_home.join(CONFIG_TOML_FILE);
if let Some(event) = unstable_features_warning_event(
config
.config_layer_stack
.effective_config()
.get("features")
.and_then(TomlValue::as_table),
effective_config.get("features").and_then(TomlValue::as_table),
config.suppress_unstable_features_warning,
&config.features,
&config_path.display().to_string(),
@@ -1217,7 +1215,27 @@ impl Session {
} else {
shell::default_user_shell()
};
let use_executor_shell_snapshots = config.features.enabled(Feature::ShellSnapshotV2)
let credential_broker_available = config.features.enabled(Feature::NetworkProxy)
&& config
.config_layer_stack
.requirements()
.network
.as_ref()
.is_none_or(|network| network.value.enabled != Some(false));
let credential_broker_configured = credential_broker_available
&& effective_config
.get("features")
.and_then(|features| features.get("network_proxy"))
.and_then(|network_proxy| network_proxy.get("credential_broker"))
.and_then(TomlValue::as_bool)
.unwrap_or(false);
let credential_broker_active = credential_broker_configured
&& config
.permissions
.network
.as_ref()
.is_some_and(crate::config::NetworkProxySpec::credential_broker_enabled);
let prefer_executor_shell_snapshots = config.features.enabled(Feature::ShellSnapshotV2)
&& config.features.enabled(Feature::ShellTool)
&& config.features.enabled(Feature::UnifiedExec)
&& matches!(
@@ -1229,14 +1247,26 @@ impl Session {
),
codex_tools::UnifiedExecShellMode::Direct
);
let use_executor_shell_snapshots =
prefer_executor_shell_snapshots && !credential_broker_active;
let shell_snapshot = if config.features.enabled(Feature::ShellSnapshot)
&& !use_executor_shell_snapshots
&& (!use_executor_shell_snapshots || credential_broker_available)
{
let snapshot_credential_broker = credential_broker_available.then(|| {
let state = if credential_broker_active {
SnapshotCredentialBrokerState::Starting
} else {
SnapshotCredentialBrokerState::Inactive
};
watch::channel(state).0
});
ShellSnapshot::new(
config.codex_home.clone(),
thread_id,
session_telemetry.clone(),
state_db_ctx.clone(),
snapshot_credential_broker,
prefer_executor_shell_snapshots,
)
} else {
ShellSnapshot::disabled()
@@ -1355,6 +1385,17 @@ impl Session {
} else {
(None, None)
};
if let Some(network_proxy) = network_proxy.as_ref()
&& config
.permissions
.network
.as_ref()
.is_some_and(crate::config::NetworkProxySpec::credential_broker_enabled)
{
turn_environments.set_snapshot_credential_broker(
SnapshotCredentialBrokerState::Ready(network_proxy.proxy()),
);
}
// Hooks and extensions share one stable thread-owned MCP runtime handle.
let mcp_runtime = Arc::new(McpRuntime::empty(
+14 -2
View File
@@ -5644,8 +5644,12 @@ async fn session_configuration_apply_permission_profile_accepts_direct_write_roo
);
}
#[test_case::test_case(false; "ordinary_proxy")]
#[test_case::test_case(true; "credential_broker")]
#[tokio::test]
async fn active_profile_update_rebuilds_network_proxy_config() -> std::io::Result<()> {
async fn active_profile_update_rebuilds_network_proxy_config(
credential_broker: bool,
) -> std::io::Result<()> {
let codex_home = tempfile::tempdir().expect("create codex home");
let cwd = tempfile::tempdir().expect("create cwd");
let permissions = PermissionsToml {
@@ -5690,7 +5694,14 @@ async fn active_profile_update_rebuilds_network_proxy_config() -> std::io::Resul
]),
};
let base_config = ConfigToml {
features: Some(toml::from_str("network_proxy = true").expect("valid features")),
features: Some(
toml::from_str(if credential_broker {
"network_proxy = { enabled = true, credential_broker = true }"
} else {
"network_proxy = true"
})
.expect("valid features"),
),
default_permissions: Some("locked-down".to_string()),
permissions: Some(permissions),
..Default::default()
@@ -5752,6 +5763,7 @@ async fn active_profile_update_rebuilds_network_proxy_config() -> std::io::Resul
.expect("selected profile proxy should become the session proxy config");
assert_eq!(network.proxy_host_and_port(), "127.0.0.1:43128");
assert!(!network.socks_enabled());
assert_eq!(network.credential_broker_enabled(), credential_broker);
Ok(())
}
+43 -6
View File
@@ -6,7 +6,9 @@ use crate::config::TokenBudgetConfig;
use crate::environment_selection::EnvironmentConfigOrigin;
use crate::environment_selection::TurnEnvironmentSnapshot;
use crate::exec_policy::AllowPrefixRules;
use crate::shell_snapshot::ShellSnapshot;
use crate::shell_snapshot::ShellSnapshotFile;
use crate::shell_snapshot::ShellSnapshotSandbox;
use crate::tools::sandboxing::executor_windows_sandbox_level;
use arc_swap::ArcSwap;
use codex_core_plugins::PluginCommandAttribution;
@@ -56,6 +58,7 @@ pub(crate) struct TurnEnvironment {
/// OS reported by the selected executor; `None` for legacy executors.
pub(crate) executor_platform_os: Option<String>,
pub(crate) shell_snapshot: ShellSnapshotTask,
pub(crate) shell_snapshot_builder: Option<Box<ShellSnapshot>>,
pub(crate) shell_snapshot_v2_supported: bool,
}
@@ -76,6 +79,7 @@ impl TurnEnvironment {
shell,
executor_platform_os: None,
shell_snapshot: futures::future::ready(None).boxed().shared(),
shell_snapshot_builder: None,
shell_snapshot_v2_supported: false,
}
}
@@ -99,14 +103,47 @@ impl TurnEnvironment {
config
}
pub(crate) fn shell_snapshot(&self, cwd: &AbsolutePathBuf) -> Option<AbsolutePathBuf> {
if self.selection.cwd != PathUri::from_abs_path(cwd) {
pub(crate) async fn shell_snapshot(
&self,
cwd: &AbsolutePathBuf,
command: &[String],
shell: &shell::Shell,
config: &Config,
sandbox: Option<ShellSnapshotSandbox>,
) -> Option<Arc<ShellSnapshotFile>> {
let credential_broker_enabled = config
.permissions
.network
.as_ref()
.is_some_and(|spec| spec.enabled() && spec.credential_broker_enabled());
let cwd_matches_selection = self.selection.cwd == PathUri::from_abs_path(cwd);
if !config.features.enabled(Feature::ShellSnapshot)
|| (!cwd_matches_selection && !credential_broker_enabled)
|| command.len() < 3
|| !matches!(command[1].as_str(), "-lc" | "-c")
|| (command[1] == "-c" && !credential_broker_enabled)
{
return None;
}
self.shell_snapshot
.peek()?
.as_deref()
.map(ShellSnapshotFile::path)
if credential_broker_enabled {
let sandbox = sandbox?;
self.shell_snapshot_builder
.as_ref()?
.as_ref()
.clone()
.build(
Arc::clone(&self.environment),
PathUri::from_abs_path(cwd),
Some(shell.clone()),
command[1] == "-lc",
self.shell_environment_policy().clone(),
Some(sandbox),
)
.await
.filter(|snapshot| snapshot.is_brokered_for(self.shell_environment_policy()))
} else {
self.shell_snapshot.peek()?.clone()
}
}
pub(crate) fn cwd(&self) -> &PathUri {
+386 -38
View File
@@ -1,3 +1,4 @@
use std::collections::HashMap;
use std::io::ErrorKind;
use std::path::Path;
use std::process::Stdio;
@@ -9,29 +10,47 @@ use crate::StateDbHandle;
use crate::rollout::list::find_thread_path_by_id_str;
use crate::shell::Shell;
use crate::shell::ShellType;
use crate::shell::get_shell;
use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
use anyhow::bail;
use codex_exec_server::Environment;
use codex_network_proxy::CREDENTIAL_BROKER_ACTIVE_ENV_KEY;
use codex_network_proxy::NetworkProxy;
use codex_network_proxy::brokered_credential_dummy_env_keys;
use codex_network_proxy::brokered_credential_env_keys;
use codex_network_proxy::credential_broker_provider_context_env_keys;
use codex_network_proxy::is_credential_broker_provider_env_key;
use codex_otel::SessionTelemetry;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ShellEnvironmentPolicy;
use codex_protocol::shell_environment::create_env_from_vars;
use codex_shell_command::shell_snapshot::CapturedSnapshot;
use codex_shell_command::shell_snapshot::PreparedSnapshot;
use codex_shell_command::shell_snapshot::SnapshotCaptureOptions;
use codex_shell_command::shell_snapshot::SnapshotCredentialEnvironment;
use codex_shell_command::shell_snapshot::SnapshotStartup;
use codex_shell_command::shell_snapshot::prepare_snapshot_credentials;
use codex_shell_command::shell_snapshot::snapshot_capture_script;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use tokio::fs;
use tokio::process::Command;
use tokio::sync::watch;
use tokio::time::timeout;
use tracing::Instrument;
use tracing::info_span;
#[path = "shell_snapshot_sandbox.rs"]
mod sandbox;
pub(crate) use sandbox::ShellSnapshotSandbox;
pub(crate) use sandbox::snapshot_read_permissions;
#[derive(Clone)]
pub(crate) struct ShellSnapshot {
config: Option<Arc<ShellSnapshotConfig>>,
credential_broker: Option<watch::Sender<SnapshotCredentialBrokerState>>,
}
struct ShellSnapshotConfig {
@@ -39,10 +58,33 @@ struct ShellSnapshotConfig {
session_id: ThreadId,
session_telemetry: SessionTelemetry,
state_db: Option<StateDbHandle>,
credential_broker: Option<watch::Receiver<SnapshotCredentialBrokerState>>,
prefer_executor_snapshots: bool,
}
#[derive(Clone, PartialEq)]
pub(crate) enum SnapshotCredentialBrokerState {
Starting,
Inactive,
Unavailable,
Ready(NetworkProxy),
}
pub(crate) struct ShellSnapshotFile {
path: AbsolutePathBuf,
credentials: Option<SnapshotCredentials>,
}
struct SnapshotCredentials {
network_proxy: NetworkProxy,
shell_environment_policy: ShellEnvironmentPolicy,
credential_env: HashMap<String, String>,
}
struct SnapshotCredentialBroker {
network_proxy: NetworkProxy,
shell_environment_policy: ShellEnvironmentPolicy,
allow_login_shell: bool,
}
const SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(10);
@@ -55,6 +97,8 @@ impl ShellSnapshot {
session_id: ThreadId,
session_telemetry: SessionTelemetry,
state_db: Option<StateDbHandle>,
credential_broker: Option<watch::Sender<SnapshotCredentialBrokerState>>,
prefer_executor_snapshots: bool,
) -> Self {
Self {
config: Some(Arc::new(ShellSnapshotConfig {
@@ -62,12 +106,31 @@ impl ShellSnapshot {
session_id,
session_telemetry,
state_db,
credential_broker: credential_broker.as_ref().map(watch::Sender::subscribe),
prefer_executor_snapshots,
})),
credential_broker,
}
}
pub(crate) fn disabled() -> Self {
Self { config: None }
Self {
config: None,
credential_broker: None,
}
}
pub(crate) fn set_credential_broker(&self, state: SnapshotCredentialBrokerState) -> bool {
self.credential_broker.as_ref().is_some_and(|sender| {
let previous = sender.send_replace(state.clone());
previous != state
})
}
pub(crate) fn should_rebuild_inherited(&self) -> bool {
self.credential_broker.as_ref().is_some_and(|sender| {
!matches!(*sender.borrow(), SnapshotCredentialBrokerState::Inactive)
})
}
pub(crate) async fn build(
@@ -75,8 +138,11 @@ impl ShellSnapshot {
environment: Arc<Environment>,
cwd: PathUri,
shell: Option<Shell>,
allow_login_shell: bool,
shell_environment_policy: ShellEnvironmentPolicy,
sandbox: Option<ShellSnapshotSandbox>,
) -> Option<Arc<ShellSnapshotFile>> {
let config = self.config.as_ref()?;
let config = Arc::clone(self.config.as_ref()?);
if environment.is_remote() {
return None;
}
@@ -85,16 +151,55 @@ impl ShellSnapshot {
// TODO(anp): Migrate shell snapshot creation to accept PathUri and defer native
// conversion to the spawned shell process.
let cwd = cwd.to_abs_path().ok()?;
Self::build_for_cwd(Arc::clone(config), cwd, shell).await
drop(self);
Self::build_for_cwd(
config,
cwd,
shell,
allow_login_shell,
shell_environment_policy,
sandbox,
)
.await
}
async fn build_for_cwd(
config: Arc<ShellSnapshotConfig>,
cwd: AbsolutePathBuf,
shell: Shell,
allow_login_shell: bool,
shell_environment_policy: ShellEnvironmentPolicy,
sandbox: Option<ShellSnapshotSandbox>,
) -> Option<Arc<ShellSnapshotFile>> {
let snapshot_span = info_span!("shell_snapshot", thread_id = %config.session_id);
async {
let credential_broker = if let Some(receiver) = config.credential_broker.as_ref() {
let mut receiver = receiver.clone();
if matches!(&*receiver.borrow(), SnapshotCredentialBrokerState::Starting)
&& receiver.changed().await.is_err()
{
return None;
}
let state = receiver.borrow().clone();
match state {
SnapshotCredentialBrokerState::Starting
| SnapshotCredentialBrokerState::Unavailable => return None,
SnapshotCredentialBrokerState::Inactive if config.prefer_executor_snapshots => {
return None;
}
SnapshotCredentialBrokerState::Inactive => None,
SnapshotCredentialBrokerState::Ready(network_proxy) if sandbox.is_some() => {
Some(SnapshotCredentialBroker {
network_proxy,
shell_environment_policy,
allow_login_shell,
})
}
SnapshotCredentialBrokerState::Ready(_) => return None,
}
} else {
None
};
let timer = config
.session_telemetry
.start_timer("codex.shell_snapshot.duration_ms", &[("version", "v1")]);
@@ -104,6 +209,8 @@ impl ShellSnapshot {
&cwd,
&shell,
config.state_db.clone(),
credential_broker,
sandbox.as_ref(),
)
.await;
let success_tag = if snapshot.is_ok() { "true" } else { "false" };
@@ -127,6 +234,8 @@ impl ShellSnapshot {
session_cwd: &AbsolutePathBuf,
shell: &Shell,
state_db: Option<StateDbHandle>,
credential_broker: Option<SnapshotCredentialBroker>,
sandbox: Option<&ShellSnapshotSandbox>,
) -> std::result::Result<ShellSnapshotFile, &'static str> {
// File to store the snapshot
let extension = match shell.shell_type {
@@ -156,19 +265,35 @@ impl ShellSnapshot {
});
// Make the new snapshot.
if let Err(err) = write_shell_snapshot(shell.shell_type, &temp_path, session_cwd).await {
let credentials = write_shell_snapshot(
shell,
&temp_path,
session_cwd,
credential_broker.as_ref(),
sandbox,
)
.await
.map_err(|err| {
tracing::warn!(
"Failed to create shell snapshot for {}: {err:?}",
shell.name()
);
return Err("write_failed");
}
"write_failed"
})?;
tracing::info!(
"Shell snapshot successfully created: {}",
temp_path.display()
);
if let Err(err) = validate_snapshot(shell, &temp_path, session_cwd).await {
if let Err(err) = validate_snapshot(
shell,
&temp_path,
session_cwd,
credential_broker.as_ref(),
sandbox,
)
.await
{
tracing::error!("Shell snapshot validation failed: {err:?}");
remove_snapshot_file(&temp_path).await;
return Err("validation_failed");
@@ -180,14 +305,48 @@ impl ShellSnapshot {
return Err("write_failed");
}
Ok(ShellSnapshotFile { path })
Ok(ShellSnapshotFile { path, credentials })
}
}
impl ShellSnapshotFile {
pub(crate) fn is_brokered_for(
&self,
shell_environment_policy: &ShellEnvironmentPolicy,
) -> bool {
self.credentials.as_ref().is_some_and(|credentials| {
&credentials.shell_environment_policy == shell_environment_policy
})
}
pub(crate) fn path(&self) -> AbsolutePathBuf {
self.path.clone()
}
pub(crate) fn restore_credentials(
&self,
env: &mut HashMap<String, String>,
shell_environment_policy: &ShellEnvironmentPolicy,
) {
let Some(credentials) = self.credentials.as_ref() else {
return;
};
// This snapshot belongs to one command and was captured under this exact policy.
if &credentials.shell_environment_policy != shell_environment_policy {
return;
}
let mut snapshot_env = credentials.credential_env.clone();
snapshot_env.insert(
CREDENTIAL_BROKER_ACTIVE_ENV_KEY.to_string(),
"1".to_string(),
);
credentials
.network_proxy
.restore_brokered_credentials(&mut snapshot_env, &mut []);
snapshot_env.remove(CREDENTIAL_BROKER_ACTIVE_ENV_KEY);
env.extend(snapshot_env);
}
}
impl Drop for ShellSnapshotFile {
@@ -202,17 +361,17 @@ impl Drop for ShellSnapshotFile {
}
async fn write_shell_snapshot(
shell_type: ShellType,
shell: &Shell,
output_path: &AbsolutePathBuf,
cwd: &AbsolutePathBuf,
) -> Result<()> {
credential_broker: Option<&SnapshotCredentialBroker>,
sandbox: Option<&ShellSnapshotSandbox>,
) -> Result<Option<SnapshotCredentials>> {
let shell_type = shell.shell_type;
if shell_type == ShellType::PowerShell || shell_type == ShellType::Cmd {
bail!("Shell snapshot not supported yet for {shell_type:?}");
}
let shell =
get_shell(shell_type).with_context(|| format!("No available shell for {shell_type:?}"))?;
let snapshot = capture_snapshot(&shell, cwd).await?;
let (snapshot, credentials) = capture_snapshot(shell, cwd, credential_broker, sandbox).await?;
if let Some(parent) = output_path.parent() {
let parent_display = parent.display();
@@ -226,30 +385,170 @@ async fn write_shell_snapshot(
.await
.with_context(|| format!("Failed to write snapshot to {snapshot_path}"))?;
Ok(())
Ok(credentials)
}
async fn capture_snapshot(shell: &Shell, cwd: &AbsolutePathBuf) -> Result<String> {
async fn capture_snapshot(
shell: &Shell,
cwd: &AbsolutePathBuf,
credential_broker: Option<&SnapshotCredentialBroker>,
sandbox: Option<&ShellSnapshotSandbox>,
) -> Result<(String, Option<SnapshotCredentials>)> {
let shell_type = shell.shell_type;
let shell_startup = if credential_broker.is_some_and(|broker| !broker.allow_login_shell) {
SnapshotStartup::NonInteractive
} else {
SnapshotStartup::Interactive
};
let script = snapshot_capture_script(
shell_type,
SnapshotCaptureOptions {
startup: SnapshotStartup::Interactive,
startup: shell_startup,
declarations: true,
environment: false,
environment: credential_broker.is_some(),
},
)
.ok_or_else(|| anyhow!("Shell snapshotting is not yet supported for {shell_type:?}"))?;
let captured = run_shell_script(shell, &script, cwd).await?;
CapturedSnapshot::parse(shell_type, captured.as_bytes())
.map(|snapshot| snapshot.render_script())
.ok_or_else(|| anyhow!("Invalid shell snapshot capture"))
let shell_mode = if credential_broker.is_none_or(|broker| broker.allow_login_shell) {
SnapshotShellMode::Login
} else {
SnapshotShellMode::NonLogin
};
let raw_snapshot = run_script_with_timeout(
shell,
&script,
SNAPSHOT_TIMEOUT,
shell_mode,
cwd,
credential_broker,
sandbox,
)
.await?;
let capture = CapturedSnapshot::parse(shell_type, raw_snapshot.as_bytes())
.ok_or_else(|| anyhow!("invalid shell snapshot capture"))?;
let Some(credential_broker) = credential_broker else {
return Ok((capture.render_script(), None));
};
let original_env = std::str::from_utf8(capture.environment)?
.split('\0')
.filter_map(|entry| entry.split_once('='))
.map(|(key, value)| (key.to_string(), value.to_string()))
.collect::<HashMap<_, _>>();
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();
credential_broker
.network_proxy
.restore_brokered_credentials(&mut restored_env, &mut []);
let mut discovery_env = restored_env.clone();
replace_provider_context_with_inherited(
&mut discovery_env,
&inherited_env,
credential_broker_provider_context_env_keys(),
);
for (key, value) in &policy.r#set {
if !discovery_env.contains_key(key)
|| credential_broker_provider_context_env_keys()
.any(|context_key| context_key.eq_ignore_ascii_case(key))
{
discovery_env.insert(key.clone(), value.clone());
}
}
credential_broker
.network_proxy
.apply_to_env(&mut discovery_env);
let brokered_keys = brokered_credential_dummy_env_keys(&discovery_env);
let mut env = create_env_from_vars(
restored_env
.iter()
.map(|(key, value)| (key.clone(), value.clone())),
policy,
/*thread_id*/ None,
);
replace_provider_context_with_inherited(
&mut env,
&inherited_env,
credential_broker_provider_context_env_keys(),
);
credential_broker.network_proxy.apply_to_env(&mut env);
let allowed_brokered_keys = brokered_credential_dummy_env_keys(&env);
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);
}
}
let PreparedSnapshot {
script: snapshot, ..
} = prepare_snapshot_credentials(
&capture,
SnapshotCredentialEnvironment {
original: &original_env,
restored: &restored_env,
configured: &policy.r#set,
discovered: &discovery_env,
allowed: &snapshot_env,
is_allowed_unset: &|key| {
create_env_from_vars(
std::iter::once((key.to_string(), String::new())),
policy,
/*thread_id*/ None,
)
.contains_key(key)
},
brokered_keys: &brokered_keys,
brokered_alias_keys: &[],
allowed_brokered_keys: &allowed_brokered_keys,
},
|value| {
credential_broker
.network_proxy
.virtualize_brokered_text(value, &env)
},
)
.ok_or_else(|| anyhow!("shell snapshot contains a credential outside supported exports"))?;
let credential_env = brokered_credential_env_keys(&env)
.map(str::to_string)
.chain(allowed_brokered_keys)
.filter_map(|key| env.get(&key).map(|value| (key, value.clone())))
.collect();
Ok((
snapshot,
Some(SnapshotCredentials {
network_proxy: credential_broker.network_proxy.clone(),
shell_environment_policy: policy.clone(),
credential_env,
}),
))
}
fn replace_provider_context_with_inherited(
env: &mut HashMap<String, String>,
inherited_env: &HashMap<String, String>,
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());
}
}
}
#[derive(Clone, Copy)]
enum SnapshotShellMode<'a> {
Login,
NonLogin,
Validation(&'a AbsolutePathBuf),
}
async fn validate_snapshot(
shell: &Shell,
snapshot_path: &AbsolutePathBuf,
cwd: &AbsolutePathBuf,
credential_broker: Option<&SnapshotCredentialBroker>,
sandbox: Option<&ShellSnapshotSandbox>,
) -> Result<()> {
let snapshot_path_display = snapshot_path.display();
let script = format!("set -e; . \"{snapshot_path_display}\"");
@@ -257,41 +556,90 @@ async fn validate_snapshot(
shell,
&script,
SNAPSHOT_TIMEOUT,
/*use_login_shell*/ false,
SnapshotShellMode::Validation(snapshot_path),
cwd,
credential_broker,
sandbox,
)
.await
.map(|_| ())
}
async fn run_shell_script(shell: &Shell, script: &str, cwd: &AbsolutePathBuf) -> Result<String> {
run_script_with_timeout(
shell,
script,
SNAPSHOT_TIMEOUT,
/*use_login_shell*/ true,
cwd,
)
.await
}
async fn run_script_with_timeout(
shell: &Shell,
script: &str,
snapshot_timeout: Duration,
use_login_shell: bool,
shell_mode: SnapshotShellMode<'_>,
cwd: &AbsolutePathBuf,
credential_broker: Option<&SnapshotCredentialBroker>,
sandbox: Option<&ShellSnapshotSandbox>,
) -> Result<String> {
let args = shell.derive_exec_args(script, use_login_shell);
let suppress_startup_files =
credential_broker.is_some() && matches!(shell_mode, SnapshotShellMode::Validation(_));
let mut args = shell.derive_exec_args(script, matches!(shell_mode, SnapshotShellMode::Login));
if suppress_startup_files && shell.shell_type == ShellType::Zsh {
args[1] = "-fc".to_string();
}
let shell_name = shell.name();
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())),
);
if suppress_startup_files {
env.remove("BASH_ENV");
}
credential_broker.network_proxy.apply_to_env(&mut env);
prepared_env = Some(env);
}
if let Some(sandbox) = sandbox {
let snapshot_read_path = match shell_mode {
SnapshotShellMode::Validation(path) => Some(path),
SnapshotShellMode::Login | SnapshotShellMode::NonLogin => None,
};
return sandbox
.run(
args,
cwd,
prepared_env.unwrap_or_else(|| std::env::vars().collect()),
snapshot_timeout,
shell_name,
snapshot_read_path,
)
.await;
}
// Handler is kept as guard to control the drop. The `mut` pattern is required because .args()
// returns a ref of handler.
let mut handler = Command::new(&args[0]);
codex_protocol::shell_environment::scrub_non_inheritable_env_vars(handler.as_std_mut());
handler.args(&args[1..]);
handler.stdin(Stdio::null());
handler.current_dir(cwd);
if let Some(env) = prepared_env {
handler.env_clear();
handler.envs(env);
}
codex_protocol::shell_environment::scrub_non_inheritable_env_vars(handler.as_std_mut());
#[cfg(unix)]
unsafe {
handler.pre_exec(|| {
+184
View File
@@ -0,0 +1,184 @@
use crate::exec::ExecCapturePolicy;
use crate::exec::ExecExpiration;
use crate::sandboxing::ExecOptions;
use crate::sandboxing::ExecRequest;
use crate::sandboxing::execute_env;
use crate::tools::sandboxing::SandboxAttempt;
use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
use anyhow::bail;
use codex_network_proxy::NetworkProxy;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::AdditionalPermissionProfile;
use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::PermissionProfile;
use codex_sandboxing::SandboxCommand;
use codex_sandboxing::SandboxManager;
use codex_sandboxing::SandboxTransformRequest;
use codex_sandboxing::SandboxType;
use codex_sandboxing::policy_transforms::merge_permission_profiles;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;
#[derive(Clone)]
pub(crate) struct ShellSnapshotSandbox {
sandbox: SandboxType,
sandbox_requested: bool,
permissions: PermissionProfile,
enforce_managed_network: bool,
sandbox_policy_cwd: PathUri,
workspace_roots: Vec<PathUri>,
codex_linux_sandbox_exe: Option<PathBuf>,
use_legacy_landlock: bool,
windows_sandbox_level: WindowsSandboxLevel,
windows_sandbox_private_desktop: bool,
network: Option<NetworkProxy>,
environment_id: String,
additional_permissions: Option<AdditionalPermissionProfile>,
}
pub(crate) fn snapshot_read_permissions(
snapshot_path: &AbsolutePathBuf,
permissions: &PermissionProfile,
sandbox_cwd: &PathUri,
) -> Option<AdditionalPermissionProfile> {
if let Ok(cwd) = sandbox_cwd.to_abs_path()
&& permissions
.file_system_sandbox_policy()
.can_read_local_path_with_cwd(snapshot_path.as_path(), cwd.as_path())
{
return None;
}
Some(AdditionalPermissionProfile {
network: None,
file_system: Some(FileSystemPermissions::from_read_write_roots(
Some(vec![snapshot_path.clone()]),
/*write*/ None,
)),
})
}
impl ShellSnapshotSandbox {
pub(crate) fn new(
attempt: &SandboxAttempt<'_>,
network: Option<&NetworkProxy>,
environment_id: &str,
additional_permissions: Option<&AdditionalPermissionProfile>,
) -> Self {
Self {
sandbox: attempt.sandbox,
sandbox_requested: attempt.sandbox_requested,
permissions: attempt.permissions.clone(),
enforce_managed_network: attempt.enforce_managed_network,
sandbox_policy_cwd: attempt.sandbox_cwd.clone(),
workspace_roots: attempt.workspace_roots.to_vec(),
codex_linux_sandbox_exe: attempt.codex_linux_sandbox_exe.cloned(),
use_legacy_landlock: attempt.use_legacy_landlock,
windows_sandbox_level: attempt.windows_sandbox_level,
windows_sandbox_private_desktop: attempt.windows_sandbox_private_desktop,
network: attempt.network_proxy(network).cloned(),
environment_id: environment_id.to_string(),
additional_permissions: additional_permissions.cloned(),
}
}
pub(crate) async fn run(
&self,
args: Vec<String>,
cwd: &AbsolutePathBuf,
mut env: HashMap<String, String>,
snapshot_timeout: Duration,
shell_name: &str,
snapshot_read_path: Option<&AbsolutePathBuf>,
) -> Result<String> {
if self.sandbox_requested && self.sandbox == SandboxType::None {
bail!("shell snapshot sandbox cannot be enforced on this host");
}
let managed_network = if let Some(network) = self.network.as_ref() {
let prepared = network
.prepare_for_optional_environment(env, Some(&self.environment_id))
.context("failed to prepare managed network for shell snapshot")?;
env = prepared.env;
Some(prepared.sandbox_context)
} else {
None
};
let (program, args) = args
.split_first()
.ok_or_else(|| anyhow!("shell snapshot command is empty"))?;
let snapshot_permissions = snapshot_read_path.and_then(|path| {
snapshot_read_permissions(
path,
&self
.permissions
.clone()
.materialize_project_roots_with_path_uris(&self.workspace_roots),
&self.sandbox_policy_cwd,
)
});
let additional_permissions = merge_permission_profiles(
self.additional_permissions.as_ref(),
snapshot_permissions.as_ref(),
);
let manager = SandboxManager::new();
let request = manager
.transform(SandboxTransformRequest {
command: SandboxCommand {
program: program.clone().into(),
args: args.to_vec(),
cwd: PathUri::from_abs_path(cwd),
env,
managed_network,
additional_permissions,
},
permissions: &self.permissions,
sandbox: self.sandbox,
enforce_managed_network: self.enforce_managed_network,
environment_id: Some(&self.environment_id),
network: self.network.as_ref(),
sandbox_policy_cwd: &self.sandbox_policy_cwd,
codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.as_deref(),
use_legacy_landlock: self.use_legacy_landlock,
windows_sandbox_level: self.windows_sandbox_level,
windows_sandbox_private_desktop: self.windows_sandbox_private_desktop,
})
.context("failed to prepare shell snapshot sandbox")?;
let workspace_roots = self
.workspace_roots
.iter()
.map(PathUri::to_abs_path)
.collect::<std::io::Result<Vec<_>>>()
.context("failed to resolve shell snapshot workspace roots")?;
let request = ExecRequest::from_sandbox_exec_request(
request,
ExecOptions {
expiration: ExecExpiration::Timeout(snapshot_timeout),
capture_policy: ExecCapturePolicy::FullBuffer,
},
workspace_roots,
)
.context("failed to prepare shell snapshot execution")?;
let output = tokio::time::timeout(
snapshot_timeout,
execute_env(request, /*stdout_stream*/ None),
)
.await
.map_err(|_| anyhow!("Snapshot command timed out for {shell_name}"))?
.with_context(|| format!("Failed to execute sandboxed {shell_name}"))?;
if output.exit_code != 0 {
bail!(
"Snapshot command exited with status {}: {}",
output.exit_code,
output.stderr.text
);
}
Ok(output.stdout.text)
}
}
+471 -3
View File
@@ -1,10 +1,22 @@
use super::*;
#[cfg(unix)]
use crate::config::NetworkProxySpec;
#[cfg(unix)]
use codex_network_proxy::NetworkProxyConfig;
#[cfg(unix)]
use codex_network_proxy::brokered_credential_binding_env_keys;
#[cfg(unix)]
use codex_protocol::config_types::EnvironmentVariablePattern;
#[cfg(unix)]
use codex_protocol::models::PermissionProfile;
use core_test_support::PathBufExt;
use core_test_support::PathExt;
use pretty_assertions::assert_eq;
#[cfg(unix)]
use std::os::unix::ffi::OsStrExt;
use std::path::PathBuf;
#[cfg(unix)]
use std::process::Command;
#[cfg(target_os = "linux")]
use std::process::Command as StdCommand;
@@ -81,7 +93,16 @@ fn assert_posix_snapshot_sections(snapshot: &str) {
async fn get_snapshot(shell_type: ShellType) -> Result<String> {
let dir = tempdir()?;
let path = dir.path().join("snapshot.sh");
write_shell_snapshot(shell_type, &path.abs(), &dir.path().abs()).await?;
let shell = crate::shell::get_shell(shell_type)
.with_context(|| format!("No available shell for {shell_type:?}"))?;
write_shell_snapshot(
&shell,
&path.abs(),
&dir.path().abs(),
/*credential_broker*/ None,
/*sandbox*/ None,
)
.await?;
let content = fs::read_to_string(&path).await?;
Ok(content)
}
@@ -108,6 +129,443 @@ fn snapshot_file_name_parser_supports_legacy_and_suffixed_names() {
);
}
#[cfg(unix)]
#[tokio::test]
async fn inactive_profiles_keep_snapshots_but_active_brokers_require_sandbox() -> Result<()> {
let dir = tempdir()?;
std::fs::create_dir(dir.path().join(".codex"))?;
std::fs::write(
dir.path().join(".codex/startup.sh"),
"printf started > startup-ran\n",
)?;
let session_id = ThreadId::new();
let session_telemetry = SessionTelemetry::new(
session_id,
"test",
"test",
/*account_id*/ None,
/*account_email*/ None,
/*auth_mode*/ None,
"test".to_string(),
/*log_user_prompts*/ false,
"test".to_string(),
codex_protocol::protocol::SessionSource::Cli,
);
let shell = Shell {
shell_type: ShellType::Bash,
shell_path: PathBuf::from("/bin/bash"),
};
let permission_profile = PermissionProfile::workspace_write();
let mut network_config = NetworkProxyConfig::default();
network_config.set_credential_broker_enabled(/*enabled*/ true);
let network_spec = NetworkProxySpec::from_config_and_constraints(
network_config,
/*requirements*/ None,
&permission_profile,
)?;
let started_proxy = network_spec
.start_proxy(
&permission_profile,
/*policy_decider*/ None,
/*blocked_request_observer*/ None,
/*enable_network_approval_flow*/ false,
Default::default(),
)
.await?;
for (state, prefer_executor_snapshots, expect_snapshot) in [
(SnapshotCredentialBrokerState::Unavailable, false, false),
(SnapshotCredentialBrokerState::Inactive, false, true),
(SnapshotCredentialBrokerState::Inactive, true, false),
(
SnapshotCredentialBrokerState::Ready(started_proxy.proxy()),
false,
false,
),
] {
let (_sender, receiver) = watch::channel(state);
let config = Arc::new(ShellSnapshotConfig {
codex_home: dir.path().abs(),
session_id,
session_telemetry: session_telemetry.clone(),
state_db: None,
credential_broker: Some(receiver),
prefer_executor_snapshots,
});
let snapshot = tokio::time::timeout(
SNAPSHOT_TIMEOUT,
ShellSnapshot::build_for_cwd(
config,
dir.path().abs(),
shell.clone(),
/*allow_login_shell*/ false,
ShellEnvironmentPolicy {
r#set: HashMap::from([(
"BASH_ENV".to_string(),
"./.codex/startup.sh".to_string(),
)]),
..ShellEnvironmentPolicy::default()
},
/*sandbox*/ None,
),
)
.await?;
assert_eq!(snapshot.is_some(), expect_snapshot);
}
assert!(!dir.path().join("startup-ran").exists());
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn snapshot_discovers_and_redacts_shell_initialized_credentials() -> Result<()> {
let dir = tempdir()?;
let startup = dir.path().join("startup.sh");
std::fs::write(
&startup,
"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\
export GH_ENTERPRISE_TOKEN='ghp_enterprise_secret'\n\
export GH_HOST='attacker.example'\n\
export OPENAI_API_KEY='sk-proj-snapshot-secret'\n\
export AUTH_BUNDLE=\"GitHub $GH_TOKEN\n\
OpenAI $OPENAI_API_KEY\"\n\
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",
)?;
let mut network_config = NetworkProxyConfig::default();
network_config.set_credential_broker_enabled(/*enabled*/ true);
let permission_profile = PermissionProfile::workspace_write();
let network_spec = NetworkProxySpec::from_config_and_constraints(
network_config,
/*requirements*/ None,
&permission_profile,
)?;
let started_proxy = network_spec
.start_proxy(
&permission_profile,
/*policy_decider*/ None,
/*blocked_request_observer*/ None,
/*enable_network_approval_flow*/ false,
Default::default(),
)
.await?;
let network_proxy = started_proxy.proxy();
let shell = Shell {
shell_type: ShellType::Bash,
shell_path: PathBuf::from("/bin/bash"),
};
let trusted_startup = ("BASH_ENV".to_string(), startup.display().to_string());
let shell_environment_policy = ShellEnvironmentPolicy {
exclude: vec![EnvironmentVariablePattern::new_case_insensitive("HOME")],
r#set: HashMap::from([
trusted_startup.clone(),
("GH_HOST".to_string(), "github.example.com".to_string()),
(
"OPENAI_IDENTITY_TOKEN_FILE".to_string(),
"identity-token-secret".to_string(),
),
(
"PATH".to_string(),
format!("/repository-controlled:{}", std::env::var("PATH")?),
),
]),
..ShellEnvironmentPolicy::default()
};
let credential_broker = SnapshotCredentialBroker {
network_proxy: network_proxy.clone(),
shell_environment_policy: shell_environment_policy.clone(),
allow_login_shell: false,
};
let path = dir.path().join("snapshot.sh").abs();
let credentials = write_shell_snapshot(
&shell,
&path,
&dir.path().abs(),
Some(&credential_broker),
/*sandbox*/ None,
)
.await?;
let snapshot = fs::read_to_string(&path).await?;
for secret in [
"ghp_shell_only_secret",
"ghp_readonly_secret",
"ghp_enterprise_secret",
"sk-proj-snapshot-secret",
"identity-token-secret",
] {
assert!(!snapshot.contains(secret), "snapshot exposed {secret}");
}
assert!(!snapshot.contains("attacker.example"));
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("declare -rx HOMEBREW_GITHUB_API_TOKEN=\"${GITHUB_TOKEN-}\""));
validate_snapshot(
&shell,
&path,
&dir.path().abs(),
Some(&credential_broker),
/*sandbox*/ None,
)
.await?;
let validation_path = dir.path().join("validation.sh").abs();
fs::write(
&validation_path,
"test \"${HOME-missing}\" = missing && \
test \"${CODEX_NETWORK_PROXY_CREDENTIAL_BROKER_ACTIVE-}\" = 1\n",
)
.await?;
validate_snapshot(
&shell,
&validation_path,
&dir.path().abs(),
Some(&credential_broker),
/*sandbox*/ None,
)
.await?;
let filtered_startup_broker = SnapshotCredentialBroker {
network_proxy: network_proxy.clone(),
shell_environment_policy: ShellEnvironmentPolicy {
r#set: HashMap::from([trusted_startup.clone()]),
include_only: vec![
EnvironmentVariablePattern::new_case_insensitive("PATH"),
EnvironmentVariablePattern::new_case_insensitive("EXCLUDED_PARENT_HOME"),
],
..ShellEnvironmentPolicy::default()
},
allow_login_shell: false,
};
let filtered_startup_path = dir.path().join("filtered-startup-snapshot.sh").abs();
write_shell_snapshot(
&shell,
&filtered_startup_path,
&dir.path().abs(),
Some(&filtered_startup_broker),
/*sandbox*/ None,
)
.await?;
assert!(
!fs::read_to_string(&filtered_startup_path)
.await?
.contains("EXCLUDED_PARENT_HOME")
);
let inherited_secret = "ghp_inherited_enterprise_secret";
let inherited_context = HashMap::from([(
"GH_HOST".to_string(),
"github.inherited.example".to_string(),
)]);
let mut inherited_discovery_env = HashMap::from([
(
"GH_ENTERPRISE_TOKEN".to_string(),
inherited_secret.to_string(),
),
("GH_HOST".to_string(), "attacker.example".to_string()),
]);
replace_provider_context_with_inherited(
&mut inherited_discovery_env,
&inherited_context,
credential_broker_provider_context_env_keys(),
);
assert_eq!(
inherited_discovery_env.get("GH_HOST").map(String::as_str),
Some("github.inherited.example")
);
network_proxy.apply_to_env(&mut inherited_discovery_env);
let mut inherited_allowed_env = HashMap::from([(
"GH_ENTERPRISE_TOKEN".to_string(),
inherited_secret.to_string(),
)]);
replace_provider_context_with_inherited(
&mut inherited_allowed_env,
&inherited_context,
brokered_credential_binding_env_keys(&inherited_discovery_env),
);
network_proxy.apply_to_env(&mut inherited_allowed_env);
assert_eq!(
inherited_allowed_env.get("GH_HOST").map(String::as_str),
Some("github.inherited.example")
);
assert_ne!(
inherited_allowed_env
.get("GH_ENTERPRISE_TOKEN")
.map(String::as_str),
Some(inherited_secret)
);
let mut inherited_snapshot = format!("export GH_ENTERPRISE_TOKEN={inherited_secret}\n");
assert!(
network_proxy.virtualize_brokered_text(&mut inherited_snapshot, &inherited_allowed_env)
);
assert!(!inherited_snapshot.contains(inherited_secret));
let snapshot_file = ShellSnapshotFile { path, credentials };
let mut env = HashMap::from([("GH_HOST".to_string(), "github.example.com".to_string())]);
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"),
("OPENAI_API_KEY", "sk-proj-snapshot-secret"),
] {
assert_eq!(env.get(key).map(String::as_str), Some(value), "{key}");
}
assert_eq!(
env.get("OPENAI_BASE_URL").map(String::as_str),
Some("https://api.snapshot.example/v1")
);
let unbrokered_replay = Command::new("/bin/bash")
.arg("-c")
.arg(". \"$1\" && printf '%s\\n%s' \"$AUTH_HEADER\" \"$AUTH_BUNDLE\"")
.arg("snapshot")
.arg(snapshot_file.path().as_path())
.env_clear()
.envs(&env)
.output()?;
assert!(unbrokered_replay.status.success());
assert_eq!(
String::from_utf8(unbrokered_replay.stdout)?,
"Bearer ghp_shell_only_secret\nGitHub ghp_shell_only_secret\nOpenAI sk-proj-snapshot-secret"
);
network_proxy.apply_to_env(&mut env);
assert_ne!(env["GH_TOKEN"], "ghp_shell_only_secret");
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())
.env_clear()
.envs(&env)
.output()?;
assert!(
replay.status.success(),
"snapshot replay failed: {replay:?}"
);
assert_eq!(
String::from_utf8(replay.stdout)?,
format!(
"{}\nBearer {}\nGitHub {}\nOpenAI {}",
env["GITHUB_TOKEN"], env["GH_TOKEN"], env["GH_TOKEN"], env["OPENAI_API_KEY"]
)
);
let filtered_policy = ShellEnvironmentPolicy {
include_only: vec![EnvironmentVariablePattern::new_case_insensitive("GH_HOST")],
..ShellEnvironmentPolicy::default()
};
let mut filtered_env = HashMap::new();
snapshot_file.restore_credentials(&mut filtered_env, &filtered_policy);
assert!(filtered_env.is_empty());
let partially_filtered_credential_broker = SnapshotCredentialBroker {
network_proxy: network_proxy.clone(),
shell_environment_policy: ShellEnvironmentPolicy {
r#set: HashMap::from([trusted_startup.clone()]),
include_only: vec![
EnvironmentVariablePattern::new_case_insensitive("BASH_ENV"),
EnvironmentVariablePattern::new_case_insensitive("GH_TOKEN"),
EnvironmentVariablePattern::new_case_insensitive("AUTH_HEADER"),
EnvironmentVariablePattern::new_case_insensitive("AUTH_BUNDLE"),
],
..ShellEnvironmentPolicy::default()
},
allow_login_shell: false,
};
for (github_token, openai_api_key) in [
("ghp_shell_only_secret", "sk-proj-snapshot-secret"),
(env["GH_TOKEN"].as_str(), env["OPENAI_API_KEY"].as_str()),
] {
std::fs::write(
&startup,
format!(
"export GH_TOKEN='{github_token}'\n\
export GITHUB_TOKEN=\"$GH_TOKEN\"\n\
export AUTH_HEADER=\"Bearer $GH_TOKEN\"\n\
export OPENAI_API_KEY='{openai_api_key}'\n\
export AUTH_BUNDLE=\"GitHub $GH_TOKEN\n\
OpenAI $OPENAI_API_KEY\"\n\
unset OPENAI_API_KEY\n"
),
)?;
let partially_filtered_path = dir.path().join("partially-filtered-snapshot.sh").abs();
write_shell_snapshot(
&shell,
&partially_filtered_path,
&dir.path().abs(),
Some(&partially_filtered_credential_broker),
/*sandbox*/ None,
)
.await?;
let filtered_replay = Command::new("/bin/bash")
.arg("-c")
.arg(". \"$1\" && printf '%s\\n%s' \"$AUTH_HEADER\" \"${AUTH_BUNDLE-unset}\"")
.arg("snapshot")
.arg(partially_filtered_path.as_path())
.env_clear()
.env("GH_TOKEN", "ghp_filtered_dummy")
.output()?;
assert!(filtered_replay.status.success());
assert_eq!(
String::from_utf8(filtered_replay.stdout)?,
"Bearer ghp_filtered_dummy\nunset"
);
}
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",
)?;
let mut previously_brokered_env = HashMap::from([(
"GH_TOKEN".to_string(),
"ghp_hidden_alias_secret".to_string(),
)]);
network_proxy.apply_to_env(&mut previously_brokered_env);
let inherited_credential_broker = SnapshotCredentialBroker {
network_proxy,
shell_environment_policy: ShellEnvironmentPolicy {
r#set: HashMap::from([trusted_startup]),
..ShellEnvironmentPolicy::default()
},
allow_login_shell: false,
};
let inherited_path = dir.path().join("inherited-snapshot.sh").abs();
write_shell_snapshot(
&shell,
&inherited_path,
&dir.path().abs(),
Some(&inherited_credential_broker),
/*sandbox*/ None,
)
.await?;
assert!(
!fs::read_to_string(inherited_path)
.await?
.contains("ghp_hidden_alias_secret")
);
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn try_create_creates_and_deletes_snapshot_file() -> Result<()> {
@@ -123,6 +581,8 @@ async fn try_create_creates_and_deletes_snapshot_file() -> Result<()> {
&dir.path().abs(),
&shell,
/*state_db*/ None,
/*credential_broker*/ None,
/*sandbox*/ None,
)
.await
.expect("snapshot should be created");
@@ -152,6 +612,8 @@ async fn try_create_uses_distinct_generation_paths() -> Result<()> {
&dir.path().abs(),
&shell,
/*state_db*/ None,
/*credential_broker*/ None,
/*sandbox*/ None,
)
.await
.expect("initial snapshot should be created");
@@ -161,6 +623,8 @@ async fn try_create_uses_distinct_generation_paths() -> Result<()> {
&dir.path().abs(),
&shell,
/*state_db*/ None,
/*credential_broker*/ None,
/*sandbox*/ None,
)
.await
.expect("refreshed snapshot should be created");
@@ -218,8 +682,10 @@ async fn snapshot_shell_does_not_inherit_stdin() -> Result<()> {
&shell,
&script,
Duration::from_secs(2),
/*use_login_shell*/ true,
SnapshotShellMode::Login,
&home,
/*credential_broker*/ None,
/*sandbox*/ None,
)
.await
.context("run snapshot command")?;
@@ -261,8 +727,10 @@ async fn timed_out_snapshot_shell_is_terminated() -> Result<()> {
&shell,
&script,
Duration::from_secs(1),
/*use_login_shell*/ true,
SnapshotShellMode::Login,
&dir.path().abs(),
/*credential_broker*/ None,
/*sandbox*/ None,
)
.await
.expect_err("snapshot shell should time out");
+10 -1
View File
@@ -163,7 +163,16 @@ pub(crate) async fn execute_user_shell_command(
.await;
return;
};
let shell_snapshot_location = turn_environment.shell_snapshot(&cwd);
let shell_snapshot = turn_environment
.shell_snapshot(
&cwd,
&display_command,
environment_shell,
&turn_context.config,
/*sandbox*/ None,
)
.await;
let shell_snapshot_location = shell_snapshot.as_ref().map(|snapshot| snapshot.path());
let shell_environment_policy = turn_environment.shell_environment_policy();
let mut exec_env_map = create_env(shell_environment_policy, Some(session.thread_id));
inject_session_env(&mut exec_env_map, session.session_id());
+67 -9
View File
@@ -17,6 +17,7 @@ use codex_core_plugins::PLUGIN_METRICS_OUTPUT_ENV_VAR;
use codex_install_context::InstallContext;
#[cfg(target_os = "macos")]
use codex_network_proxy::CODEX_PROXY_GIT_SSH_COMMAND_MARKER;
use codex_network_proxy::CREDENTIAL_BROKER_ACTIVE_ENV_KEY;
use codex_network_proxy::CUSTOM_CA_ENV_KEYS;
use codex_network_proxy::PROXY_ACTIVE_ENV_KEY;
use codex_network_proxy::PROXY_ENV_KEYS;
@@ -200,8 +201,22 @@ fn prepare_powershell_command_for_elevated_windows_sandbox_with_fallback(
command
}
pub(crate) fn prepare_brokered_shell_snapshot_env(
env: &mut HashMap<String, String>,
shell_snapshot: Option<&AbsolutePathBuf>,
) {
if shell_snapshot.is_some()
&& env
.get(CREDENTIAL_BROKER_ACTIVE_ENV_KEY)
.is_some_and(|active| active == "1")
{
env.remove("BASH_ENV");
}
}
/// POSIX-only helper: for commands produced by `Shell::derive_exec_args`
/// for Bash/Zsh/sh of the form `[shell_path, "-lc", "<script>"]`, and
/// for Bash/Zsh/sh of the form `[shell_path, "-lc", "<script>"]` or brokered
/// `[shell_path, "-c", "<script>"]`, and
/// when a snapshot is configured on the session shell, rewrite the argv
/// to a single non-login shell that sources the snapshot before running
/// the original script:
@@ -209,6 +224,11 @@ fn prepare_powershell_command_for_elevated_windows_sandbox_with_fallback(
/// shell -lc "<script>"
/// => user_shell -c ". SNAPSHOT (best effort); exec shell -c <script>"
///
/// Brokered Bash and Zsh commands targeting the session shell run in that process
/// to preserve captured shell functions and avoid asking patched Zsh to exec itself.
/// Brokered Zsh wrappers use `-f` so startup files cannot overwrite credentials
/// after the proxy has replaced them with dummies.
///
/// This wrapper script uses POSIX constructs (`if`, `.`, `exec`) so it can
/// be run by Bash/Zsh/sh. On non-matching commands, or when command cwd does
/// not match the snapshot cwd, this is a no-op.
@@ -247,15 +267,31 @@ pub(crate) fn maybe_wrap_shell_lc_with_snapshot(
return command.to_vec();
}
let brokered = env
.get(CREDENTIAL_BROKER_ACTIVE_ENV_KEY)
.is_some_and(|active| active == "1");
let flag = command[1].as_str();
if flag != "-lc" {
if flag != "-lc" && !(brokered && flag == "-c") {
return command.to_vec();
}
let snapshot_path = snapshot.to_string_lossy();
let shell_path = session_shell.shell_path.to_string_lossy();
let command_uses_session_zsh =
session_shell.shell_type == ShellType::Zsh && command[0] == shell_path.as_ref();
let reuse_session_shell = brokered
&& command[0] == shell_path.as_ref()
&& matches!(session_shell.shell_type, ShellType::Bash | ShellType::Zsh);
let original_shell_is_zsh = command_uses_session_zsh
|| codex_shell_command::shell_detect::detect_shell_type(&command[0])
== Some(ShellType::Zsh);
let brokered_zsh_flag = if flag == "-lc" { "-lfc" } else { "-fc" };
let original_shell_flag = if brokered && original_shell_is_zsh {
brokered_zsh_flag
} else {
"-c"
};
let original_shell = shell_single_quote(&command[0]);
let original_script = shell_single_quote(&command[2]);
let snapshot_path = shell_single_quote(snapshot_path.as_ref());
let trailing_args = command[3..]
.iter()
@@ -292,17 +328,34 @@ pub(crate) fn maybe_wrap_shell_lc_with_snapshot(
proxy_exports,
runtime_path_prepend_exports,
]);
let run_original = if reuse_session_shell {
command[2].clone()
} else {
let original_script = shell_single_quote(&command[2]);
format!("exec '{original_shell}' {original_shell_flag} '{original_script}'{trailing_args}")
};
let rewritten_script = if override_exports.is_empty() {
format!(
"if . '{snapshot_path}' >/dev/null 2>&1; then :; fi\n\nexec '{original_shell}' -c '{original_script}'{trailing_args}"
)
format!("if . '{snapshot_path}' >/dev/null 2>&1; then :; fi\n\n{run_original}")
} else {
format!(
"{override_captures}\n\nif . '{snapshot_path}' >/dev/null 2>&1; then :; fi\n\n{override_exports}\n\nexec '{original_shell}' -c '{original_script}'{trailing_args}"
"{override_captures}\n\nif . '{snapshot_path}' >/dev/null 2>&1; then :; fi\n\n{override_exports}\n\n{run_original}"
)
};
vec![shell_path.to_string(), "-c".to_string(), rewritten_script]
let wrapper_flag = if brokered && session_shell.shell_type == ShellType::Zsh {
brokered_zsh_flag
} else {
"-c"
};
let mut rewritten = vec![
shell_path.to_string(),
wrapper_flag.to_string(),
rewritten_script,
];
if reuse_session_shell {
rewritten.extend_from_slice(&command[3..]);
}
rewritten
}
fn build_override_exports(
@@ -328,6 +381,11 @@ fn build_proxy_env_exports(env: &HashMap<String, String>) -> (String, String) {
.copied()
.chain(codex_network_proxy::brokered_credential_env_keys(env))
.chain(CUSTOM_CA_ENV_KEYS)
.chain(
env.get(CREDENTIAL_BROKER_ACTIVE_ENV_KEY)
.filter(|active| active.as_str() == "1")
.map(|_| "BASH_ENV"),
)
.filter(|key| is_valid_shell_variable_name(key))
.collect::<Vec<_>>();
keys.sort_unstable();
@@ -390,7 +448,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 export {key}=\"${{{value_var}}}\"; else 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"
)
})
.collect::<Vec<_>>()
+139 -6
View File
@@ -454,6 +454,47 @@ fn maybe_wrap_shell_lc_with_snapshot_preserves_trailing_args() {
);
}
#[test]
fn maybe_wrap_shell_lc_with_snapshot_reuses_brokered_session_zsh() {
let dir = tempdir().expect("create temp dir");
let snapshot_path = dir.path().join("snapshot.sh");
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());
for (flag, expected_flag) in [("-c", "-fc"), ("-lc", "-lfc")] {
let command = vec![
"/bin/zsh".to_string(),
flag.to_string(),
"printf '%s %s' \"$0\" \"$1\"".to_string(),
"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,
&session_shell,
Some(&shell_snapshot),
&HashMap::new(),
&env,
&RuntimePathPrepends::default(),
);
assert_eq!(rewritten[0], "/bin/zsh");
assert_eq!(rewritten[1], expected_flag);
assert!(rewritten[2].contains(&format!(
"if . '{}' >/dev/null 2>&1; then :; fi",
shell_single_quote(&shell_snapshot.to_string_lossy())
)));
assert!(rewritten[2].ends_with(&command[2]));
assert!(!rewritten[2].contains("exec '/bin/zsh'"));
assert_eq!(rewritten[3..], command[3..]);
}
}
#[test]
fn maybe_wrap_shell_lc_with_snapshot_restores_explicit_override_precedence() {
let dir = tempdir().expect("create temp dir");
@@ -757,15 +798,67 @@ fn maybe_wrap_shell_lc_with_snapshot_restores_proxy_env_from_process_env() {
);
}
#[tokio::test]
async fn snapshot_wrapper_preserves_readonly_dummy_credentials() -> anyhow::Result<()> {
let proxy = test_credential_broker_network_proxy().await?;
let dir = tempdir()?;
let snapshot = dir.path().join("snapshot.sh");
std::fs::write(&snapshot, "# Snapshot file\ntypeset +x GH_TOKEN\n")?;
let mut env = HashMap::from([(
"GH_TOKEN".to_string(),
"ghp_abcdefghijklmnopqrstuvwxyz0123456789".to_string(),
)]);
proxy.apply_to_env(&mut env);
let dummy = env["GH_TOKEN"].clone();
assert_ne!(dummy, "ghp_abcdefghijklmnopqrstuvwxyz0123456789");
for (shell_type, path) in [(ShellType::Bash, "/bin/bash"), (ShellType::Zsh, "/bin/zsh")] {
if !std::path::Path::new(path).exists() {
continue;
}
let (shell, snapshot) = shell_with_snapshot(shell_type, path, snapshot.abs());
let command = vec![
path.to_string(),
"-c".to_string(),
"exec /bin/sh -c 'printf %s \"$GH_TOKEN\"'".to_string(),
];
let mut rewritten = maybe_wrap_shell_lc_with_snapshot(
&command,
&shell,
Some(&snapshot),
&HashMap::new(),
&env,
&RuntimePathPrepends::default(),
);
// Global startup can mark an inherited dummy readonly before the wrapper runs.
rewritten[2].insert_str(0, "readonly GH_TOKEN\n");
let output = Command::new(&rewritten[0])
.args(&rewritten[1..])
.env_clear()
.envs(&env)
.output()?;
assert!(output.status.success(), "{path}: {output:?}");
assert_eq!(String::from_utf8(output.stdout)?, dummy);
}
Ok(())
}
#[tokio::test]
async fn snapshot_wrapper_replays_dummy_and_preserves_unbrokered_credentials() -> anyhow::Result<()>
{
let proxy = test_credential_broker_network_proxy().await?;
let dir = tempdir()?;
let snapshot = dir.path().join("snapshot.sh");
let startup = dir.path().join("startup.sh");
std::fs::write(&startup, "export OPENAI_API_KEY='sk-startup-secret'\n")?;
let snapshot_dir = dir.path().join("snapshots");
std::fs::create_dir(&snapshot_dir)?;
let snapshot = snapshot_dir.join("snapshot.sh");
std::fs::write(
&snapshot,
"# Snapshot file\nexport OPENAI_API_KEY='stale'\nexport GITHUB_TOKEN='ghp_snapshot_only'\nexport GH_HOST='github.example.com'\n",
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",
startup.display(),
dir.path().display()
),
)?;
let (shell, snapshot) = shell_with_snapshot(ShellType::Bash, "/bin/bash", snapshot.abs());
let mut env = HashMap::from([
@@ -774,14 +867,18 @@ async fn snapshot_wrapper_replays_dummy_and_preserves_unbrokered_credentials() -
"sk-proj-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_".to_string(),
),
("GITHUB_TOKEN".to_string(), String::new()),
("BASH_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 command = vec![
"/bin/bash".to_string(),
"-lc".to_string(),
"printf '%s\\n%s\\n%s' \"$OPENAI_API_KEY\" \"${GITHUB_TOKEN-unset}\" \"$GH_HOST\""
.to_string(),
"-c".to_string(),
"corp_auth \"$1\"".to_string(),
"brokered-shell".to_string(),
"github.example.com".to_string(),
];
let rewritten = maybe_wrap_shell_lc_with_snapshot(
&command,
@@ -794,7 +891,7 @@ async fn snapshot_wrapper_replays_dummy_and_preserves_unbrokered_credentials() -
let output = Command::new(&rewritten[0])
.args(&rewritten[1..])
.env_clear()
.envs(env)
.envs(&env)
.output()?;
assert!(output.status.success(), "command failed: {output:?}");
@@ -802,6 +899,42 @@ async fn snapshot_wrapper_replays_dummy_and_preserves_unbrokered_credentials() -
String::from_utf8_lossy(&output.stdout),
format!("{dummy}\nghp_snapshot_only\ngithub.example.com")
);
if std::path::Path::new("/bin/zsh").exists() {
std::fs::write(
dir.path().join(".zshenv"),
"export OPENAI_API_KEY='sk-startup-secret'\n",
)?;
let renamed_zsh = dir.path().join("zsh.exe");
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));
let command = vec![
renamed_zsh.display().to_string(),
"-lc".to_string(),
"printf '%s\\n%s' \"$OPENAI_API_KEY\" \"$ZDOTDIR\"".to_string(),
];
let rewritten = maybe_wrap_shell_lc_with_snapshot(
&command,
&zsh,
Some(&snapshot),
&HashMap::new(),
&env,
&RuntimePathPrepends::default(),
);
assert_eq!(rewritten[1], "-lfc");
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(())
}
+125 -17
View File
@@ -15,6 +15,8 @@ use crate::sandboxing::ExecServerEnvConfig;
use crate::sandboxing::SandboxPermissions;
use crate::session::turn_context::TurnEnvironment;
use crate::shell::ShellType;
use crate::shell_snapshot::ShellSnapshotSandbox;
use crate::shell_snapshot::snapshot_read_permissions;
use crate::tools::flat_tool_name;
use crate::tools::network_approval::NetworkApprovalSpec;
use crate::tools::runtimes::RuntimePathPrepends;
@@ -54,6 +56,7 @@ use codex_tools::UnifiedExecShellMode;
use codex_utils_path_uri::PathUri;
use std::collections::HashMap;
use std::io;
use std::path::PathBuf;
use std::time::Duration;
use tokio_util::sync::CancellationToken;
@@ -261,22 +264,11 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecAttempt> for UnifiedExecRunt
let base_command = &req.command;
let windows_sandbox_proxy_settings_mode = ctx.session.windows_sandbox_proxy_settings_mode;
let session_shell = ctx.session.user_shell();
let shell = req
let environment_shell = req
.turn_environment
.shell
.as_ref()
.unwrap_or(session_shell.as_ref());
let environment_is_remote = req.turn_environment.environment.is_remote();
let shell_snapshot_location = if environment_is_remote {
None
} else {
// TODO(anp): Make shell snapshot lookup accept PathUri.
let native_cwd = req
.cwd
.to_abs_path()
.map_err(|err| ToolError::Rejected(err.to_string()))?;
req.turn_environment.shell_snapshot(&native_cwd)
};
let (file_system_sandbox_policy, _) = attempt.permissions.to_runtime_permissions();
let launch_sandbox_permissions = sandbox_permissions_preserving_denied_reads(
req.sandbox_permissions,
@@ -286,7 +278,71 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecAttempt> for UnifiedExecRunt
req.network.as_ref(),
launch_sandbox_permissions,
));
let env = exec_env_for_sandbox_permissions(&req.env, launch_sandbox_permissions);
let environment_is_remote = req.turn_environment.environment.is_remote();
let credential_broker_available = !environment_is_remote
&& req.network.is_some()
&& ctx
.step_context
.turn
.config
.permissions
.network
.as_ref()
.is_some_and(crate::config::NetworkProxySpec::credential_broker_enabled);
if credential_broker_available
&& managed_network.is_some()
&& matches!(self.shell_mode, UnifiedExecShellMode::ZshFork(_))
{
return Err(ToolError::Rejected(
"credential brokerage does not yet support shell_zsh_fork; disable shell_zsh_fork to use the broker".to_string(),
));
}
let requested_shell = (credential_broker_available
&& (req.shell_type != environment_shell.shell_type
|| base_command.first().is_some_and(|path| {
path != environment_shell.shell_path.to_string_lossy().as_ref()
})))
.then(|| {
base_command.first().map(|path| crate::shell::Shell {
shell_type: req.shell_type,
shell_path: PathBuf::from(path),
})
})
.flatten();
let shell = requested_shell.as_ref().unwrap_or(environment_shell);
let shell_snapshot = if environment_is_remote
|| credential_broker_available
&& launch_sandbox_permissions.requires_escalated_permissions()
{
None
} else {
// TODO(anp): Make shell snapshot lookup accept PathUri.
let native_cwd = req
.cwd
.to_abs_path()
.map_err(|err| ToolError::Rejected(err.to_string()))?;
req.turn_environment
.shell_snapshot(
&native_cwd,
base_command,
shell,
&ctx.step_context.turn.config,
Some(ShellSnapshotSandbox::new(
attempt,
managed_network,
&req.turn_environment.selection.environment_id,
req.additional_permissions.as_ref(),
)),
)
.await
};
let shell_snapshot_location = shell_snapshot.as_ref().map(|snapshot| snapshot.path());
let mut env = exec_env_for_sandbox_permissions(&req.env, launch_sandbox_permissions);
if let Some(snapshot) = shell_snapshot.as_ref()
&& (managed_network.is_some() || base_command.get(1).is_some_and(|flag| flag == "-lc"))
{
snapshot.restore_credentials(&mut env, req.turn_environment.shell_environment_policy());
}
let (mut env, managed_network_context, network_proxy_launch) = match managed_network {
Some(network) if environment_is_remote => {
let mut launch = network.remote_launch_config().await.map_err(|err| {
@@ -349,6 +405,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecAttempt> for UnifiedExecRunt
}
None => (env, None, None),
};
super::prepare_brokered_shell_snapshot_env(&mut env, shell_snapshot_location.as_ref());
let explicit_env_overrides = req.explicit_env_overrides.clone();
let metrics_sidecar = sidecar_for_command(
ctx,
@@ -392,6 +449,30 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecAttempt> for UnifiedExecRunt
&runtime_path_prepends,
)
};
let brokered_shell_snapshot_missing = !environment_is_remote
&& managed_network.is_some()
&& env
.get(codex_network_proxy::CREDENTIAL_BROKER_ACTIVE_ENV_KEY)
.is_some_and(|active| active == "1")
&& command
.get(1)
.is_some_and(|flag| matches!(flag.as_str(), "-lc" | "-c"))
&& !shell_snapshot_location
.as_ref()
.is_some_and(|snapshot| snapshot.exists());
if brokered_shell_snapshot_missing && let Some(network) = managed_network {
if cfg!(unix)
&& matches!(
shell.shell_type,
ShellType::Bash | ShellType::Zsh | ShellType::Sh
)
{
return Err(ToolError::Rejected(
"credential brokerage could not create a protected shell snapshot".to_string(),
));
}
network.restore_and_disable_brokered_credentials(&mut env, &mut command);
}
if req.shell_snapshot.is_some() {
let exports =
runtime_path_prepends.shell_exports_after_snapshot(&explicit_env_overrides);
@@ -401,6 +482,15 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecAttempt> for UnifiedExecRunt
*script = format!("{exports}\n{script}");
}
}
if !environment_is_remote
&& matches!(shell.shell_type, ShellType::PowerShell | ShellType::Cmd)
&& env
.get(codex_network_proxy::CREDENTIAL_BROKER_ACTIVE_ENV_KEY)
.is_some_and(|active| active == "1")
&& let Some(network) = managed_network
{
network.restore_and_disable_brokered_credentials(&mut env, &mut command);
}
let command = prepare_powershell_command_for_elevated_windows_sandbox(
&command,
Some(&req.shell_type),
@@ -416,9 +506,25 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecAttempt> for UnifiedExecRunt
let sidecar_permissions = metrics_sidecar
.as_ref()
.map(PluginMetricsSidecar::additional_permissions);
let snapshot_permissions = shell_snapshot_location
.as_ref()
.filter(|_| {
env.get(codex_network_proxy::CREDENTIAL_BROKER_ACTIVE_ENV_KEY)
.is_some_and(|active| active == "1")
})
.and_then(|path| {
snapshot_read_permissions(
path,
&req.turn_environment
.permission_profile_with_workspace_roots(),
attempt.sandbox_cwd,
)
});
let internal_permissions =
merge_permission_profiles(sidecar_permissions.as_ref(), snapshot_permissions.as_ref());
let additional_permissions = merge_permission_profiles(
req.additional_permissions.as_ref(),
sidecar_permissions.as_ref(),
internal_permissions.as_ref(),
);
let permissions = TerminalPermissions::for_launch(
&req.turn_environment,
@@ -434,7 +540,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecAttempt> for UnifiedExecRunt
SandboxPermissions::UseDefault
},
req.additional_permissions.as_ref(),
sidecar_permissions.as_ref(),
internal_permissions.as_ref(),
);
if let UnifiedExecShellMode::ZshFork(zsh_fork_config) = &self.shell_mode {
@@ -471,7 +577,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecAttempt> for UnifiedExecRunt
.to_string(),
));
}
let process = self
let mut process = self
.manager
.open_session_with_prepared_exec_env(
req.process_id,
@@ -493,6 +599,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecAttempt> for UnifiedExecRunt
}
other => ToolError::Rejected(other.to_string()),
})?;
process._shell_snapshot = shell_snapshot;
return Ok(UnifiedExecAttempt {
process,
metrics_sidecar,
@@ -520,7 +627,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecAttempt> for UnifiedExecRunt
error @ ToolError::Codex(_) => error,
})?;
let options = unified_exec_options(attempt.network_denial_cancellation_token.clone());
let process = self
let mut process = self
.manager
.open_session_with_exec_env(
req.process_id,
@@ -539,6 +646,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecAttempt> for UnifiedExecRunt
req.turn_environment.environment.as_ref(),
)
.await?;
process._shell_snapshot = shell_snapshot;
Ok(UnifiedExecAttempt {
process,
metrics_sidecar,
@@ -34,6 +34,7 @@ use super::UNIFIED_EXEC_OUTPUT_MAX_TOKENS;
use super::UnifiedExecError;
use super::head_tail_buffer::HeadTailBuffer;
use super::process_state::ProcessState;
use crate::shell_snapshot::ShellSnapshotFile;
const EARLY_EXIT_GRACE_PERIOD: Duration = Duration::from_millis(150);
pub(crate) trait SpawnLifecycle: std::fmt::Debug + Send + Sync {
@@ -99,6 +100,8 @@ pub(crate) struct UnifiedExecProcess {
sandbox_type: SandboxType,
timed_out: AtomicBool,
_spawn_lifecycle: Option<SpawnLifecycleHandle>,
// The shell may still need to replay this file after process startup returns.
pub(crate) _shell_snapshot: Option<Arc<ShellSnapshotFile>>,
}
impl std::fmt::Debug for UnifiedExecProcess {
@@ -140,6 +143,7 @@ impl UnifiedExecProcess {
sandbox_type,
timed_out: AtomicBool::new(false),
_spawn_lifecycle: spawn_lifecycle,
_shell_snapshot: None,
}
}
@@ -2,4 +2,4 @@
source: core/src/unified_exec/stdin_approval_tests.rs
expression: reason
---
Send input to an existing terminal. This terminal retains additional permissions. It also has an internal plugin metrics write grant. The cwd is its launch directory; the terminal's current directory and state may have changed.
Send input to an existing terminal. This terminal retains additional permissions. It also has an internal filesystem grant. The cwd is its launch directory; the terminal's current directory and state may have changed.
@@ -160,7 +160,7 @@ impl TerminalPermissions {
};
let mut reason = format!("Send input to an existing terminal. {authority}");
if self.internal_permissions.is_some() {
reason.push_str(" It also has an internal plugin metrics write grant.");
reason.push_str(" It also has an internal filesystem grant.");
}
reason.push_str(" The cwd is its launch directory; the terminal's current directory and state may have changed.");
if let Some(grants) = &self.additional_permissions {
@@ -163,7 +163,10 @@ async fn internal_grants_require_review_without_exposing_paths() -> anyhow::Resu
environment.config_mut().permission_profile =
PermissionProfileSnapshot::legacy(PermissionProfile::read_only());
let grants = serde_json::from_value(json!({
"file_system": {"write": [turn.config.cwd.join("private-metrics")]}
"file_system": {
"read": [turn.config.cwd.join("private-snapshot")],
"write": [turn.config.cwd.join("private-metrics")]
}
}))?;
let permissions = TerminalPermissions::for_launch(
&environment,
@@ -189,6 +192,34 @@ async fn internal_grants_require_review_without_exposing_paths() -> anyhow::Resu
Ok(())
}
#[tokio::test]
async fn readable_snapshot_does_not_require_stdin_approval() -> anyhow::Result<()> {
let (_session, turn) = make_session_and_context().await;
let mut environment = turn.environments.primary().expect("environment").clone();
environment.config_mut().permission_profile =
PermissionProfileSnapshot::legacy(PermissionProfile::read_only());
let snapshot = turn.config.cwd.join("shell-snapshot.sh");
let grant = crate::shell_snapshot::snapshot_read_permissions(
&snapshot,
&environment.permission_profile_with_workspace_roots(),
environment.cwd(),
);
assert_eq!(grant, None);
let permissions = TerminalPermissions::for_launch(
&environment,
&turn,
TerminalSandboxSource::Native,
SandboxPermissions::UseDefault,
/*additional_permissions*/ None,
grant.as_ref(),
);
assert_eq!(
permissions.review_requirement(&permissions.policy, environment.permission_profile()),
Ok(SandboxPermissions::UseDefault)
);
Ok(())
}
#[test_case::test_case(TerminalSandboxSource::Native, SandboxPermissions::RequireEscalated; "native_disabled_sandbox_needs_review_when_enabled")]
#[test_case::test_case(TerminalSandboxSource::Executor, SandboxPermissions::UseDefault; "executor_keeps_its_restricted_token_default")]
#[tokio::test]
+346 -28
View File
@@ -14,6 +14,8 @@ use codex_protocol::approvals::NetworkApprovalProtocol;
use codex_protocol::approvals::NetworkPolicyAmendment;
use codex_protocol::approvals::NetworkPolicyRuleAction;
use codex_protocol::config_types::CollaborationMode;
#[cfg(unix)]
use codex_protocol::config_types::EnvironmentVariablePattern;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Settings;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
@@ -2798,50 +2800,148 @@ async fn spawned_subagent_execpolicy_amendment_propagates_to_parent_session() ->
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[cfg(unix)]
async fn zsh_fork_receives_brokered_github_credential_and_provider_context() -> Result<()> {
#[test_case("zsh_fork", false, false; "zsh_fork_unsupported")]
#[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")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_startup_credentials_are_brokered(
shell_mode: &'static str,
allow_login_shell: bool,
login: bool,
) -> Result<()> {
skip_if_no_network!(Ok(()));
const GH_HOST: &str = "github.example.com";
const REAL_GITHUB_TOKEN: &str =
"ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let Some(runtime) = zsh_fork_runtime("zsh-fork credential broker environment test")? else {
return Ok(());
let builder = if shell_mode == "zsh_fork" {
let Some(runtime) = zsh_fork_runtime("zsh-fork credential broker environment test")? else {
return Ok(());
};
zsh_fork_test_builder(runtime, AskForApproval::Never)
} else {
let Some(zsh) = codex_core::shell::get_shell(codex_core::shell::ShellType::Zsh) else {
return Ok(());
};
test_codex().with_user_shell(zsh).with_config(|config| {
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::Never);
config.features.enable(Feature::ShellTool).unwrap();
})
};
let outside_dir = tempfile::tempdir_in(std::env::current_dir()?)?;
let outside_path = outside_dir.path().join("unsandboxed-snapshot-write");
let outside_path_arg = shlex::try_join([outside_path.to_string_lossy().as_ref()])?;
let home = Arc::new(TempDir::new()?);
let startup_dir = home.path().join("startup");
fs::create_dir(&startup_dir)?;
let untrusted_snapshot_condition = r#"[[ "$ZSH_EXECUTION_STRING" == *"command env -0"* ]]"#;
fs::write(
startup_dir.join(".zshenv"),
format!(
"export GH_HOST='{GH_HOST}'\n\
if [[ ! -o login ]]; then export GH_ENTERPRISE_TOKEN='{REAL_GITHUB_TOKEN}'; fi\n\
export APP_SETTING EXCLUDED_SETTING\n\
export BROKERED_STARTUP_CWD=\"$PWD\"\n\
if {untrusted_snapshot_condition}; then\n\
print -r -- escaped > {outside_path_arg} 2>/dev/null || true\n\
fi\n\
function setopt() {{\n\
builtin setopt \"$@\"\n\
if {untrusted_snapshot_condition}; then\n\
print -r -- escaped > {outside_path_arg} 2>/dev/null || true\n\
fi\n\
}}\n"
),
)?;
fs::write(
startup_dir.join(".zshrc"),
format!(
"export LOGIN_SNAPSHOT_READY=ready\nexport GH_ENTERPRISE_TOKEN='{REAL_GITHUB_TOKEN}'\n"
),
)?;
fs::write(
startup_dir.join(".zprofile"),
"export LOGIN_SHELL_READY=ready\n",
)?;
fs::write(
home.path().join("config.toml"),
r#"default_permissions = "brokered"
features = { network_proxy = { enabled = true, credential_broker = true } }
permissions = { brokered = { extends = ":workspace", network = { enabled = true, allow_local_binding = true } } }
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]
ZDOTDIR = "{}"
"#,
startup_dir.display()
),
)?;
let mut builder = zsh_fork_test_builder(runtime, AskForApproval::Never)
let mut builder = builder
.with_home(home)
.with_cloud_config_bundle(managed_network_requirements_loader())
.with_config(|config| {
config.permissions.shell_environment_policy.r#set.extend([
("GH_HOST".to_string(), GH_HOST.to_string()),
(
"GH_ENTERPRISE_TOKEN".to_string(),
REAL_GITHUB_TOKEN.to_string(),
),
]);
});
.with_cloud_config_bundle(managed_network_requirements_loader());
if shell_mode == "direct" && !allow_login_shell {
let Some(bash) = codex_core::shell::get_shell(codex_core::shell::ShellType::Bash) else {
return Ok(());
};
builder = builder.with_user_shell(bash);
}
let mut builder = builder.with_config(move |config| {
config.permissions.allow_login_shell = allow_login_shell;
config.permissions.shell_environment_policy.exclude.push(
EnvironmentVariablePattern::new_case_insensitive("EXCLUDED_SETTING"),
);
config
.features
.enable(Feature::ShellSnapshot)
.expect("test config should allow ShellSnapshot override");
if shell_mode == "direct" {
config
.features
.disable(Feature::ShellZshFork)
.expect("test config should allow direct shell execution");
}
});
let server = start_mock_server().await;
let test = builder.build(&server).await?;
let snapshot_dir = test.home.path().join("shell_snapshots");
let workdir = if allow_login_shell || shell_mode == "zsh_fork" {
test.cwd.path().to_path_buf()
} else {
let workdir = test.cwd.path().join("brokered-subdirectory");
fs::create_dir(&workdir)?;
workdir
};
let call_id = "zsh-fork-brokered-github-credential";
let command = r#"printf '%s\n%s\n%s\n' "$GH_HOST" "$GH_ENTERPRISE_TOKEN" "$CODEX_NETWORK_PROXY_CREDENTIAL_BROKER_ACTIVE""#;
let event = shell_event(
call_id,
command,
/*timeout_ms*/ 10_000,
SandboxPermissions::UseDefault,
)?;
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 = 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 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],
"workdir": workdir,
"login": login,
"yield_time_ms": 10_000,
})
} else {
json!({
"cmd": command,
"workdir": workdir,
"login": login,
"yield_time_ms": 10_000,
})
};
let event = ev_function_call(call_id, "exec_command", &serde_json::to_string(&arguments)?);
let responses = mount_sse_sequence(
&server,
vec![
@@ -2866,16 +2966,234 @@ permissions = { brokered = { extends = ":workspace", network = { enabled = true,
.await?;
wait_for_completion_without_approval(&test).await;
let result = parse_result(&responses.requests()[1].function_call_output(call_id));
let output = responses.requests()[1].function_call_output(call_id);
if shell_mode == "zsh_fork" {
let output = output.to_string();
assert!(output.contains("credential brokerage does not yet support shell_zsh_fork"));
assert!(!workdir.join("captured-snapshot").exists());
assert!(!output.contains(REAL_GITHUB_TOKEN));
return Ok(());
}
let result = parse_result(&output);
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(), 3, "unexpected command output: {result:?}");
assert_eq!(values.len(), 6, "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());
assert_eq!(values[2], "1");
assert_eq!(PathBuf::from(values[3]), fs::canonicalize(&workdir)?);
assert_eq!(PathBuf::from(values[4]), fs::canonicalize(&workdir)?);
assert_eq!(values[5], if login { "ready" } else { "unset" });
assert!(
!outside_path.exists(),
"shell snapshot startup wrote outside the command sandbox"
);
let snapshot = fs::read_to_string(workdir.join("captured-snapshot"))?;
assert!(snapshot.contains("# Snapshot file"));
assert!(
!snapshot.contains(REAL_GITHUB_TOKEN),
"shell snapshot persisted a real GitHub credential"
);
Ok(())
}
#[cfg(unix)]
#[test_case("escalated")]
#[test_case("disabled")]
#[test_case("failed")]
#[test_case("heredoc")]
#[test_case("enabled")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn brokered_shell_snapshot_fallback(mode: &'static str) -> Result<()> {
skip_if_no_network!(Ok(()));
const REAL_GITHUB_TOKEN: &str =
"ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let Some(bash) = codex_core::shell::get_shell(codex_core::shell::ShellType::Bash) else {
return Ok(());
};
let startup_dir = tempfile::tempdir_in(std::env::current_dir()?)?;
let startup_path = startup_dir.path().join("startup.sh");
let observed_path = startup_dir.path().join("startup-credentials");
let observed_path_arg = shlex::try_join([observed_path.to_string_lossy().as_ref()])?;
fs::write(
&startup_path,
if mode == "escalated" {
format!("printf '%s\\n' \"${{GH_TOKEN-}}\" >> {observed_path_arg}\n")
} else if mode == "heredoc" {
"brokered_heredoc() {\ncat <<EOF\n# exports 1\n\
export LEGACY_SETTING=production\nAuthorization: ${GH_TOKEN}\n\
EOF\n}\nexport -f brokered_heredoc\n"
.to_string()
} else {
"shopt -s extglob expand_aliases\nexport CORP_REGION=west\ncorp_auth() { case \"$CORP_REGION\" in @(west|east)) printf '%s\\n' \"$CORP_REGION\" \"$GH_TOKEN\" ;; esac; }\nalias corp_login=corp_auth\n".to_string()
},
)?;
let home = Arc::new(TempDir::new()?);
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]
BASH_ENV = "{}"
GH_TOKEN = "{REAL_GITHUB_TOKEN}"
"#,
startup_path.display()
),
)?;
if mode == "failed" {
fs::write(home.path().join("shell_snapshots"), "not a directory")?;
}
if mode == "escalated" {
let rules_dir = home.path().join("rules");
fs::create_dir_all(&rules_dir)?;
fs::write(
rules_dir.join("default.rules"),
r#"prefix_rule(pattern=["printenv", "GH_TOKEN"], decision="allow")"#,
)?;
}
let approval_policy = if mode == "escalated" {
AskForApproval::OnRequest
} else {
AskForApproval::Never
};
let server = start_mock_server().await;
let mut builder = test_codex()
.with_home(home)
.with_user_shell(bash)
.with_cloud_config_bundle(managed_network_requirements_loader())
.with_config(move |config| {
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
if mode == "disabled" {
config.features.disable(Feature::ShellSnapshot)
} else {
config.features.enable(Feature::ShellSnapshot)
}
.expect("test config should allow ShellSnapshot override");
config
.features
.disable(Feature::ShellZshFork)
.expect("test config should allow direct shell execution");
});
let test = builder.build(&server).await?;
let call_id = "escalated-brokered-shell-startup";
let command = if mode == "heredoc" {
let snapshot_dir = test.home.path().join("shell_snapshots");
let snapshot_dir = shlex::try_join([snapshot_dir.to_string_lossy().as_ref()])?;
format!(
"cat {snapshot_dir}/*.sh > captured-snapshot && brokered_heredoc && printf '%s\\n' \"$GH_TOKEN\""
)
} else if mode == "enabled" {
"corp_login".to_string()
} else {
"printenv GH_TOKEN".to_string()
};
let event = shell_event(
call_id,
&command,
/*timeout_ms*/ 5_000,
if mode == "escalated" {
SandboxPermissions::RequireEscalated
} else {
SandboxPermissions::UseDefault
},
)?;
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-escalated-broker-startup-1"),
event,
ev_completed("resp-escalated-broker-startup-1"),
]),
sse(vec![
ev_assistant_message("msg-escalated-broker-startup", "done"),
ev_completed("resp-escalated-broker-startup-2"),
]),
],
)
.await;
submit_turn_preserving_active_permission_profile(
&test,
"run the allowlisted command with escalated permissions",
approval_policy,
)
.await?;
wait_for_completion_without_approval(&test).await;
let output = responses.requests()[1].function_call_output(call_id);
if matches!(mode, "disabled" | "failed") {
let output = output.to_string();
assert!(
output.contains("credential brokerage could not create a protected shell snapshot")
);
assert!(!output.contains(REAL_GITHUB_TOKEN));
return Ok(());
}
let result = parse_result(&output);
if mode == "heredoc" {
assert_eq!(
result.exit_code,
Some(0),
"heredoc replay failed: {result:?}"
);
assert!(!output.to_string().contains(REAL_GITHUB_TOKEN));
let dummy = result
.stdout
.lines()
.last()
.context("live dummy credential")?;
assert!(dummy.starts_with("ghp_"));
assert_eq!(
result.stdout,
format!(
"# exports 1\nexport LEGACY_SETTING=production\nAuthorization: {dummy}\n{dummy}\n"
)
);
let snapshot = fs::read_to_string(test.cwd.path().join("captured-snapshot"))?;
assert!(!snapshot.contains(REAL_GITHUB_TOKEN));
assert!(snapshot.contains("brokered_heredoc"));
assert!(snapshot.contains("Authorization: ${GH_TOKEN}"));
return Ok(());
}
if mode == "enabled" {
assert_eq!(result.exit_code, Some(0), "command failed: {result:?}");
let values = result.stdout.lines().collect::<Vec<_>>();
assert_eq!(values.len(), 2);
assert_eq!(values[0], "west");
assert!(values[1].starts_with("ghp_"));
assert_ne!(values[1], REAL_GITHUB_TOKEN);
return Ok(());
}
assert_eq!(
result,
CommandResult {
exit_code: Some(0),
stdout: format!("{REAL_GITHUB_TOKEN}\n"),
},
"fail-open command did not receive the ordinary real-credential environment"
);
if mode == "escalated" {
let observations = fs::read_to_string(&observed_path)?;
assert!(!observations.is_empty(), "startup file did not run");
assert!(
!observations.lines().any(|value| value != REAL_GITHUB_TOKEN),
"escalated startup observed a broker dummy"
);
}
Ok(())
}
+21 -2
View File
@@ -1066,15 +1066,34 @@ async fn shell_snapshot_deleted_after_shutdown_with_skills() -> Result<()> {
}
#[cfg(target_os = "macos")]
#[test_case::test_case(false; "default")]
#[test_case::test_case(true; "disabled_proxy_with_broker_configured")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn macos_unified_exec_resolves_command_from_tied_path_snapshot() -> Result<()> {
async fn macos_unified_exec_resolves_command_from_tied_path_snapshot(
disabled_proxy: bool,
) -> Result<()> {
let builder = test_codex()
.with_user_shell(get_shell_by_model_provided_path(&PathBuf::from("/bin/zsh")))
.with_config(|config| {
.with_config(move |config| {
config
.features
.enable(Feature::ShellSnapshot)
.expect("test config should allow feature update");
if disabled_proxy {
let mut network = codex_network_proxy::NetworkProxyConfig {
enabled: false,
..Default::default()
};
network.set_credential_broker_enabled(/*enabled*/ true);
config.permissions.network = Some(
codex_core::config::NetworkProxySpec::from_config_and_constraints(
network,
Some(Default::default()),
config.permissions.permission_profile(),
)
.expect("disabled proxy configuration"),
);
}
});
let harness = TestCodexHarness::with_builder(builder).await?;
let command_dir = harness
@@ -250,6 +250,16 @@ impl CredentialBroker {
}
}
pub(crate) fn restore_and_disable_child_env(
&self,
env: &mut HashMap<String, String>,
command: &mut [String],
) {
self.restore_child_env(env, command);
remove_env_value(env, CREDENTIAL_BROKER_ACTIVE_ENV_KEY);
remove_env_value(env, BROKERED_CREDENTIALS_ENV_KEY);
}
pub(crate) fn host_requires_mitm(&self, host: &str) -> bool {
let normalized_host = normalize_host(host);
let state = self.read_state();
@@ -136,6 +136,28 @@ fn virtualize_child_env_replaces_supported_credentials() {
assert_eq!(env.get("OPENAI_API_KEY"), Some(&github_dummy));
}
#[test]
fn unsupported_children_restore_credentials_and_disable_brokerage() {
let broker = CredentialBroker::new(/*enabled*/ true);
let github_token = "ghp_abcdefghijklmnopqrstuvwxyz1234567890";
let mut env = env_map([("GH_TOKEN", github_token)]);
broker.virtualize_child_env(&mut env);
assert_ne!(env.get("GH_TOKEN").map(String::as_str), Some(github_token));
assert_eq!(
env.get(CREDENTIAL_BROKER_ACTIVE_ENV_KEY)
.map(String::as_str),
Some("1")
);
assert!(env.contains_key(BROKERED_CREDENTIALS_ENV_KEY));
broker.restore_and_disable_child_env(&mut env, &mut []);
assert_eq!(env.get("GH_TOKEN").map(String::as_str), Some(github_token));
assert!(!env.contains_key(CREDENTIAL_BROKER_ACTIVE_ENV_KEY));
assert!(!env.contains_key(BROKERED_CREDENTIALS_ENV_KEY));
}
#[cfg(windows)]
#[test]
fn brokered_credentials_match_environment_keys_case_insensitively_on_windows() {
+11
View File
@@ -1128,6 +1128,17 @@ impl NetworkProxy {
self.state.restore_child_credentials(env, command);
}
/// Restores real credentials and removes brokerage markers for a child that cannot safely
/// consume brokered credentials while remaining on the managed network.
pub fn restore_and_disable_brokered_credentials(
&self,
env: &mut HashMap<String, String>,
command: &mut [String],
) {
self.state
.restore_and_disable_child_credentials(env, command);
}
/// Replaces allowed credentials and removes credentials excluded from the environment.
pub fn virtualize_brokered_text(
&self,
+9
View File
@@ -460,6 +460,15 @@ impl NetworkProxyState {
self.credential_broker.restore_child_env(env, command);
}
pub(crate) fn restore_and_disable_child_credentials(
&self,
env: &mut HashMap<String, String>,
command: &mut [String],
) {
self.credential_broker
.restore_and_disable_child_env(env, command);
}
pub(crate) fn virtualize_brokered_text(
&self,
text: &mut String,
@@ -57,6 +57,9 @@ impl Value {
pub(super) fn render(state: &str, aliases: &str, exports: &[Export<'_>]) -> Option<String> {
let mut output = format!("{state}{aliases}");
if !exports.is_empty() {
output.push_str("# exports (native declarations)\n");
}
for export in exports {
match export {
Export::Captured(source) => output.push_str(source),
@@ -553,7 +553,7 @@ fn brokered_snapshot_alias_values_follow_allowed_credential_overrides() {
);
let expected = match expected {
Some((value, assignment)) => (
format!("# Snapshot file\n{assignment}\n"),
format!("# Snapshot file\n# exports (native declarations)\n{assignment}\n"),
HashMap::from([("AUTH_HEADER".to_string(), value)]),
Vec::new(),
),