mirror of
https://github.com/openai/codex.git
synced 2026-11-01 19:33:35 +00:00
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:
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user