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
+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");