core: expose permission profile to shell tools

This commit is contained in:
Michael Bolin
2026-06-25 11:20:46 -07:00
parent f15df624a6
commit 17ee2bd25d
10 changed files with 642 additions and 31 deletions

View File

@@ -2,11 +2,16 @@ use codex_protocol::ThreadId;
#[cfg(test)]
use codex_protocol::config_types::EnvironmentVariablePattern;
use codex_protocol::config_types::ShellEnvironmentPolicy;
use codex_protocol::models::ActivePermissionProfile;
use codex_protocol::shell_environment;
use std::collections::HashMap;
pub use codex_protocol::shell_environment::CODEX_THREAD_ID_ENV_VAR;
/// Informational name of the active permission profile. Child processes can
/// overwrite this value, so it must not be treated as proof of enforcement.
pub const CODEX_PERMISSION_PROFILE_ENV_VAR: &str = "CODEX_PERMISSION_PROFILE";
/// Construct an environment map based on the rules in the specified policy. The
/// resulting map can be passed directly to `Command::envs()` after calling
/// `env_clear()` to ensure no unintended variables are leaked to the spawned
@@ -25,6 +30,27 @@ pub fn create_env(
shell_environment::create_env(policy, thread_id.as_deref())
}
/// Injects the selected named permission profile into a shell tool's environment.
///
/// This is applied after the shell environment policy so the runtime-selected
/// profile wins over inherited or configured values.
pub(crate) fn inject_permission_profile_env(
env: &mut HashMap<String, String>,
active_permission_profile: Option<&ActivePermissionProfile>,
) {
if cfg!(windows) {
env.retain(|key, _| !key.eq_ignore_ascii_case(CODEX_PERMISSION_PROFILE_ENV_VAR));
} else {
env.remove(CODEX_PERMISSION_PROFILE_ENV_VAR);
}
if let Some(active_permission_profile) = active_permission_profile {
env.insert(
CODEX_PERMISSION_PROFILE_ENV_VAR.to_string(),
active_permission_profile.id.clone(),
);
}
}
#[cfg(all(test, target_os = "windows"))]
fn create_env_from_vars<I>(
vars: I,

View File

@@ -10,6 +10,59 @@ fn make_vars(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
.collect()
}
#[test]
fn inject_permission_profile_env_overrides_policy_value() {
let mut env = HashMap::from([(
CODEX_PERMISSION_PROFILE_ENV_VAR.to_string(),
"stale-profile".to_string(),
)]);
inject_permission_profile_env(
&mut env,
Some(&ActivePermissionProfile::new("current-profile")),
);
assert_eq!(
env.get(CODEX_PERMISSION_PROFILE_ENV_VAR)
.map(String::as_str),
Some("current-profile")
);
}
#[test]
fn inject_permission_profile_env_removes_stale_value_without_active_profile() {
let mut env = HashMap::from([(
CODEX_PERMISSION_PROFILE_ENV_VAR.to_string(),
"stale-profile".to_string(),
)]);
inject_permission_profile_env(&mut env, /*active_permission_profile*/ None);
assert_eq!(env.get(CODEX_PERMISSION_PROFILE_ENV_VAR), None);
}
#[cfg(target_os = "windows")]
#[test]
fn inject_permission_profile_env_replaces_differently_cased_windows_key() {
let mut env = HashMap::from([(
"codex_permission_profile".to_string(),
"stale-profile".to_string(),
)]);
inject_permission_profile_env(
&mut env,
Some(&ActivePermissionProfile::new("current-profile")),
);
assert_eq!(
env,
HashMap::from([(
CODEX_PERMISSION_PROFILE_ENV_VAR.to_string(),
"current-profile".to_string(),
)])
);
}
#[test]
fn test_core_inherit_defaults_keep_sensitive_vars() {
let vars = make_vars(&[

View File

@@ -6,6 +6,7 @@ use codex_utils_absolute_path::AbsolutePathBuf;
use crate::exec::ExecCapturePolicy;
use crate::exec::ExecParams;
use crate::exec_env::create_env;
use crate::exec_env::inject_permission_profile_env;
use crate::function_tool::FunctionCallError;
use crate::maybe_emit_implicit_skill_invocation;
use crate::session::turn_context::TurnContext;
@@ -99,15 +100,19 @@ impl ShellCommandHandler {
let use_login_shell = Self::resolve_use_login_shell(params.login, allow_login_shell)?;
let command = Self::base_command(shell, &params.command, use_login_shell);
let mut env = create_env(
&turn_context.config.permissions.shell_environment_policy,
Some(session.thread_id),
);
let active_permission_profile = turn_context.config.permissions.active_permission_profile();
inject_permission_profile_env(&mut env, active_permission_profile.as_ref());
Ok(ExecParams {
command,
cwd,
expiration: params.timeout_ms.into(),
capture_policy: ExecCapturePolicy::ShellTool,
env: create_env(
&turn_context.config.permissions.shell_environment_policy,
Some(session.thread_id),
),
env,
network: turn_context.network.clone(),
network_environment_id: Some(turn_environment.environment_id.clone()),
sandbox_permissions: params.sandbox_permissions.unwrap_or_default(),

View File

@@ -1,10 +1,14 @@
use std::path::PathBuf;
use std::sync::Arc;
use codex_protocol::models::ActivePermissionProfile;
use codex_protocol::models::ShellCommandToolCallParams;
use pretty_assertions::assert_eq;
use crate::config::PermissionProfileSnapshot;
use crate::exec_env::CODEX_PERMISSION_PROFILE_ENV_VAR;
use crate::exec_env::create_env;
use crate::exec_env::inject_permission_profile_env;
use crate::sandboxing::SandboxPermissions;
use crate::session::step_context::StepContext;
use crate::session::tests::make_session_and_context;
@@ -71,7 +75,15 @@ fn assert_safe(shell: &Shell, command: &str) {
#[tokio::test]
async fn shell_command_handler_to_exec_params_uses_selected_environment() {
let (session, turn_context) = make_session_and_context().await;
let (session, mut turn_context) = make_session_and_context().await;
let permission_profile = turn_context.config.permissions.permission_profile().clone();
Arc::make_mut(&mut turn_context.config)
.permissions
.set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active(
permission_profile,
ActivePermissionProfile::new("test-profile"),
))
.expect("set active permission profile");
let command = "echo hello".to_string();
let workdir = Some("subdir".to_string());
@@ -99,10 +111,12 @@ async fn shell_command_handler_to_exec_params_uses_selected_environment() {
PathUri::from_abs_path(&selected_cwd),
Some(selected_shell),
);
let expected_env = create_env(
let mut expected_env = create_env(
&turn_context.config.permissions.shell_environment_policy,
Some(session.thread_id),
);
let active_permission_profile = turn_context.config.permissions.active_permission_profile();
inject_permission_profile_env(&mut expected_env, active_permission_profile.as_ref());
let params = ShellCommandToolCallParams {
command,
@@ -129,6 +143,12 @@ async fn shell_command_handler_to_exec_params_uses_selected_environment() {
assert_eq!(exec_params.command, expected_command);
assert_eq!(exec_params.cwd, expected_cwd);
assert_eq!(exec_params.env, expected_env);
assert_eq!(
exec_params.env.get(CODEX_PERMISSION_PROFILE_ENV_VAR),
active_permission_profile
.as_ref()
.map(|profile| &profile.id)
);
assert_eq!(exec_params.network, turn_context.network);
assert_eq!(
exec_params.network_environment_id.as_deref(),

View File

@@ -4,6 +4,7 @@ Module: runtimes
Concrete ToolRuntime implementations for specific tools. Each runtime stays
small and focused and reuses the orchestrator for approvals + sandbox + retry.
*/
use crate::exec_env::CODEX_PERMISSION_PROFILE_ENV_VAR;
use crate::exec_env::CODEX_THREAD_ID_ENV_VAR;
use crate::sandboxing::SandboxPermissions;
use crate::shell::Shell;
@@ -287,10 +288,14 @@ pub(crate) fn maybe_wrap_shell_lc_with_snapshot(
.map(|arg| format!(" '{}'", shell_single_quote(arg)))
.collect::<String>();
let mut override_env = explicit_env_overrides.clone();
if let Some(thread_id) = env.get(CODEX_THREAD_ID_ENV_VAR) {
override_env.insert(CODEX_THREAD_ID_ENV_VAR.to_string(), thread_id.clone());
for key in [CODEX_THREAD_ID_ENV_VAR, CODEX_PERMISSION_PROFILE_ENV_VAR] {
if let Some(value) = env.get(key) {
override_env.insert(key.to_string(), value.clone());
}
}
let (override_captures, override_exports) = build_override_exports(&override_env);
// Do not let a snapshot resurrect a stale profile when no named profile is active.
let (override_captures, override_exports) =
build_override_exports(&override_env, &[CODEX_PERMISSION_PROFILE_ENV_VAR]);
let (proxy_captures, proxy_exports) = build_proxy_env_exports();
let runtime_path_prepend_exports =
runtime_path_prepends.shell_exports_after_snapshot(explicit_env_overrides);
@@ -313,13 +318,18 @@ pub(crate) fn maybe_wrap_shell_lc_with_snapshot(
vec![shell_path.to_string(), "-c".to_string(), rewritten_script]
}
fn build_override_exports(explicit_env_overrides: &HashMap<String, String>) -> (String, String) {
fn build_override_exports(
explicit_env_overrides: &HashMap<String, String>,
restore_even_when_absent: &[&str],
) -> (String, String) {
let mut keys = explicit_env_overrides
.keys()
.map(String::as_str)
.chain(restore_even_when_absent.iter().copied())
.filter(|key| is_valid_shell_variable_name(key))
.collect::<Vec<_>>();
keys.sort_unstable();
keys.dedup();
build_override_exports_for_keys("__CODEX_SNAPSHOT_OVERRIDE", &keys)
}

View File

@@ -534,6 +534,78 @@ fn maybe_wrap_shell_lc_with_snapshot_restores_codex_thread_id_from_env() {
assert_eq!(String::from_utf8_lossy(&output.stdout), "nested-thread");
}
#[test]
fn maybe_wrap_shell_lc_with_snapshot_restores_permission_profile_from_env() {
let dir = tempdir().expect("create temp dir");
let snapshot_path = dir.path().join("snapshot.sh");
std::fs::write(
&snapshot_path,
"# Snapshot file\nexport CODEX_PERMISSION_PROFILE='parent-profile'\n",
)
.expect("write snapshot");
let (session_shell, shell_snapshot) =
shell_with_snapshot(ShellType::Bash, "/bin/bash", snapshot_path.abs());
let command = vec![
"/bin/bash".to_string(),
"-lc".to_string(),
"printenv CODEX_PERMISSION_PROFILE".to_string(),
];
let env = HashMap::from([(
CODEX_PERMISSION_PROFILE_ENV_VAR.to_string(),
"current-profile".to_string(),
)]);
let rewritten = maybe_wrap_shell_lc_with_snapshot(
&command,
&session_shell,
Some(&shell_snapshot),
&HashMap::new(),
&env,
&RuntimePathPrepends::default(),
);
let output = Command::new(&rewritten[0])
.args(&rewritten[1..])
.env(CODEX_PERMISSION_PROFILE_ENV_VAR, "current-profile")
.output()
.expect("run rewritten command");
assert!(output.status.success(), "command failed: {output:?}");
assert_eq!(String::from_utf8_lossy(&output.stdout), "current-profile\n");
}
#[test]
fn maybe_wrap_shell_lc_with_snapshot_unsets_absent_permission_profile() {
let dir = tempdir().expect("create temp dir");
let snapshot_path = dir.path().join("snapshot.sh");
std::fs::write(
&snapshot_path,
"# Snapshot file\nexport CODEX_PERMISSION_PROFILE='stale-profile'\n",
)
.expect("write snapshot");
let (session_shell, shell_snapshot) =
shell_with_snapshot(ShellType::Bash, "/bin/bash", snapshot_path.abs());
let command = vec![
"/bin/bash".to_string(),
"-lc".to_string(),
"printenv CODEX_PERMISSION_PROFILE".to_string(),
];
let rewritten = maybe_wrap_shell_lc_with_snapshot(
&command,
&session_shell,
Some(&shell_snapshot),
&HashMap::new(),
&HashMap::new(),
&RuntimePathPrepends::default(),
);
let output = Command::new(&rewritten[0])
.args(&rewritten[1..])
.env_remove(CODEX_PERMISSION_PROFILE_ENV_VAR)
.output()
.expect("run rewritten command");
assert_eq!(output.status.code(), Some(1));
assert_eq!(output.stdout, b"");
}
#[test]
fn maybe_wrap_shell_lc_with_snapshot_restores_proxy_env_from_process_env() {
let dir = tempdir().expect("create temp dir");

View File

@@ -13,8 +13,10 @@ use tokio_util::sync::CancellationToken;
use uuid::Uuid;
use crate::codex_thread::BackgroundTerminalInfo;
use crate::exec_env::CODEX_PERMISSION_PROFILE_ENV_VAR;
use crate::exec_env::CODEX_THREAD_ID_ENV_VAR;
use crate::exec_env::create_env;
use crate::exec_env::inject_permission_profile_env;
use crate::exec_policy::ExecApprovalRequest;
use crate::sandboxing::ExecOptions;
use crate::sandboxing::ExecRequest;
@@ -109,15 +111,19 @@ fn apply_unified_exec_env(mut env: HashMap<String, String>) -> HashMap<String, S
fn exec_env_policy_from_shell_policy(
policy: &ShellEnvironmentPolicy,
) -> codex_exec_server::ExecEnvPolicy {
let mut exclude = policy
.exclude
.iter()
.map(std::string::ToString::to_string)
.collect::<Vec<_>>();
exclude.push(CODEX_PERMISSION_PROFILE_ENV_VAR.to_string());
let mut r#set = policy.r#set.clone();
r#set.retain(|key, _| !key.eq_ignore_ascii_case(CODEX_PERMISSION_PROFILE_ENV_VAR));
codex_exec_server::ExecEnvPolicy {
inherit: policy.inherit.clone(),
ignore_default_excludes: policy.ignore_default_excludes,
exclude: policy
.exclude
.iter()
.map(std::string::ToString::to_string)
.collect(),
r#set: policy.r#set.clone(),
exclude,
r#set,
include_only: policy
.include_only
.iter()
@@ -132,7 +138,10 @@ fn env_overlay_for_exec_server(
) -> HashMap<String, String> {
request_env
.iter()
.filter(|(key, value)| local_policy_env.get(*key) != Some(*value))
.filter(|(key, value)| {
key.as_str() == CODEX_PERMISSION_PROFILE_ENV_VAR
|| local_policy_env.get(*key) != Some(*value)
})
.map(|(key, value)| (key.clone(), value.clone()))
.collect()
}
@@ -1110,6 +1119,8 @@ impl UnifiedExecProcessManager {
CODEX_THREAD_ID_ENV_VAR.to_string(),
context.session.thread_id.to_string(),
);
let active_permission_profile = context.turn.config.permissions.active_permission_profile();
inject_permission_profile_env(&mut env, active_permission_profile.as_ref());
let env = apply_unified_exec_env(env);
let exec_server_env_config = ExecServerEnvConfig {
policy: exec_env_policy_from_shell_policy(

View File

@@ -42,12 +42,20 @@ fn env_overlay_for_exec_server_keeps_runtime_changes_only() {
("HOME".to_string(), "/client-home".to_string()),
("PATH".to_string(), "/client-path".to_string()),
("SHELL_SET".to_string(), "policy".to_string()),
(
CODEX_PERMISSION_PROFILE_ENV_VAR.to_string(),
"current-profile".to_string(),
),
]);
let request_env = HashMap::from([
("HOME".to_string(), "/client-home".to_string()),
("PATH".to_string(), "/sandbox-path".to_string()),
("SHELL_SET".to_string(), "policy".to_string()),
("CODEX_THREAD_ID".to_string(), "thread-1".to_string()),
(
CODEX_PERMISSION_PROFILE_ENV_VAR.to_string(),
"current-profile".to_string(),
),
(
"CODEX_SANDBOX_NETWORK_DISABLED".to_string(),
"1".to_string(),
@@ -59,6 +67,10 @@ fn env_overlay_for_exec_server_keeps_runtime_changes_only() {
HashMap::from([
("PATH".to_string(), "/sandbox-path".to_string()),
("CODEX_THREAD_ID".to_string(), "thread-1".to_string()),
(
CODEX_PERMISSION_PROFILE_ENV_VAR.to_string(),
"current-profile".to_string(),
),
(
"CODEX_SANDBOX_NETWORK_DISABLED".to_string(),
"1".to_string()
@@ -67,6 +79,31 @@ fn env_overlay_for_exec_server_keeps_runtime_changes_only() {
);
}
#[test]
fn exec_env_policy_excludes_runtime_permission_profile() {
let policy = ShellEnvironmentPolicy {
r#set: HashMap::from([
(
"codex_permission_profile".to_string(),
"stale-profile".to_string(),
),
("KEEP".to_string(), "value".to_string()),
]),
..Default::default()
};
assert_eq!(
exec_env_policy_from_shell_policy(&policy),
codex_exec_server::ExecEnvPolicy {
inherit: policy.inherit,
ignore_default_excludes: policy.ignore_default_excludes,
exclude: vec![CODEX_PERMISSION_PROFILE_ENV_VAR.to_string()],
r#set: HashMap::from([("KEEP".to_string(), "value".to_string())]),
include_only: Vec::new(),
}
);
}
#[test]
fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() {
let cwd: codex_utils_absolute_path::AbsolutePathBuf = std::env::current_dir()

View File

@@ -10,6 +10,7 @@ use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::AskForApproval;
use crate::test_codex::TestCodex;
use crate::test_codex::TestCodexBuilder;
use crate::test_codex::test_codex;
#[derive(Clone)]
@@ -19,12 +20,7 @@ pub struct ZshForkRuntime {
}
impl ZshForkRuntime {
fn apply_to_config(
&self,
config: &mut Config,
approval_policy: AskForApproval,
permission_profile: PermissionProfile,
) {
fn apply_to_config(&self, config: &mut Config, approval_policy: AskForApproval) {
config
.features
.enable(Feature::ShellTool)
@@ -37,10 +33,6 @@ impl ZshForkRuntime {
config.main_execve_wrapper_exe = Some(self.main_execve_wrapper_exe.clone());
config.permissions.allow_login_shell = false;
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
config
.permissions
.set_permission_profile(permission_profile)
.expect("set permission profile");
}
}
@@ -86,10 +78,13 @@ pub async fn build_zsh_fork_test<F>(
where
F: FnOnce(&Path) + Send + 'static,
{
let mut builder = test_codex()
let mut builder = zsh_fork_test_builder(runtime, approval_policy)
.with_pre_build_hook(pre_build_hook)
.with_config(move |config| {
runtime.apply_to_config(config, approval_policy, permission_profile);
config
.permissions
.set_permission_profile(permission_profile)
.expect("set permission profile");
});
builder.build(server).await
}
@@ -104,10 +99,13 @@ pub async fn build_unified_exec_zsh_fork_test<F>(
where
F: FnOnce(&Path) + Send + 'static,
{
let mut builder = test_codex()
let mut builder = zsh_fork_test_builder(runtime, approval_policy)
.with_pre_build_hook(pre_build_hook)
.with_config(move |config| {
runtime.apply_to_config(config, approval_policy, permission_profile);
config
.permissions
.set_permission_profile(permission_profile)
.expect("set permission profile");
config.use_experimental_unified_exec_tool = true;
config
.features
@@ -121,6 +119,15 @@ where
builder.build(server).await
}
pub fn zsh_fork_test_builder(
runtime: ZshForkRuntime,
approval_policy: AskForApproval,
) -> TestCodexBuilder {
test_codex().with_config(move |config| {
runtime.apply_to_config(config, approval_policy);
})
}
fn find_test_zsh_path() -> Result<Option<PathBuf>> {
let repo_root = codex_utils_cargo_bin::repo_root()?;
let dotslash_zsh = repo_root.join("codex-rs/app-server/tests/suite/zsh");

View File

@@ -10,6 +10,7 @@ use codex_features::Feature;
use codex_protocol::approvals::NetworkApprovalProtocol;
use codex_protocol::approvals::NetworkPolicyAmendment;
use codex_protocol::approvals::NetworkPolicyRuleAction;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
@@ -47,6 +48,7 @@ use core_test_support::wait_for_event_with_timeout;
use core_test_support::zsh_fork::build_zsh_fork_test;
use core_test_support::zsh_fork::restrictive_workspace_write_profile;
use core_test_support::zsh_fork::zsh_fork_runtime;
use core_test_support::zsh_fork::zsh_fork_test_builder;
use pretty_assertions::assert_eq;
use regex_lite::Regex;
use serde_json::Value;
@@ -131,6 +133,8 @@ enum ActionKind {
const DEFAULT_UNIFIED_EXEC_JUSTIFICATION: &str =
"Requires escalated permissions to bypass the sandbox in tests.";
const WORKSPACE_PERMISSION_PROFILE_CONFIG: &str = r#"default_permissions = ":workspace"
"#;
impl ActionKind {
fn policy_src(&self) -> Option<&'static str> {
@@ -640,6 +644,7 @@ enum ScenarioGroup {
UnifiedExec,
}
#[derive(Debug, Eq, PartialEq)]
struct CommandResult {
exit_code: Option<i64>,
stdout: String,
@@ -683,6 +688,52 @@ async fn submit_turn(
Ok(())
}
async fn submit_turn_preserving_active_permission_profile(
test: &TestCodex,
prompt: &str,
approval_policy: AskForApproval,
) -> Result<()> {
let session_model = test.session_configured.model.clone();
test.codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: prompt.into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: codex_protocol::protocol::ThreadSettingsOverrides {
environments: Some(local_selections(test.config.cwd.clone())),
approval_policy: Some(approval_policy),
approvals_reviewer: Some(ApprovalsReviewer::User),
collaboration_mode: Some(codex_protocol::config_types::CollaborationMode {
mode: codex_protocol::config_types::ModeKind::Default,
settings: codex_protocol::config_types::Settings {
model: session_model,
reasoning_effort: None,
developer_instructions: None,
},
}),
..Default::default()
},
})
.await?;
Ok(())
}
fn assert_active_workspace_permission_profile(test: &TestCodex) {
assert_eq!(
test.session_configured
.active_permission_profile
.as_ref()
.map(|profile| profile.id.as_str()),
Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE)
);
}
fn parse_result(item: &Value) -> CommandResult {
let output_str = item
.get("output")
@@ -2696,6 +2747,325 @@ async fn matched_prefix_rule_runs_unsandboxed_under_zsh_fork() -> Result<()> {
Ok(())
}
/// Verifies that an allowlisted script retains the originating named profile
/// when its shell tool call requests escalated, unsandboxed execution.
///
/// Tool owners use this pattern when a trusted wrapper must run outside the
/// current sandbox, but then needs to launch child commands back inside the
/// same sandbox with `codex sandbox -P`. The nested invocation must also pass
/// `--include-managed-config` so it continues to honor enterprise requirements.
/// The test proves both halves of that contract: the wrapper writes outside the
/// `:workspace` sandbox, while its inherited profile name remains `:workspace`.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[cfg(unix)]
async fn allowed_escalated_shell_command_inherits_active_permission_profile() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let home = Arc::new(TempDir::new()?);
fs::write(
home.path().join("config.toml"),
WORKSPACE_PERMISSION_PROFILE_CONFIG,
)?;
let script_dir = tempfile::tempdir_in(std::env::current_dir()?)?;
let script_path = script_dir.path().join("print-permission-profile.sh");
let outside_path = script_dir.path().join("unsandboxed-marker");
fs::write(
&script_path,
format!(
r#"#!/bin/sh
# Print the inherited profile so the test can verify that it reached this script.
printenv CODEX_PERMISSION_PROFILE
touch {outside_path:?}
"#
),
)?;
let rules_dir = home.path().join("rules");
fs::create_dir_all(&rules_dir)?;
let script_pattern = serde_json::to_string(&script_path.to_string_lossy())?;
fs::write(
rules_dir.join("default.rules"),
format!(r#"prefix_rule(pattern=["/bin/sh", {script_pattern}], decision="allow")"#),
)?;
let approval_policy = AskForApproval::OnRequest;
let mut builder = test_codex().with_home(home).with_config(move |config| {
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
});
let test = builder.build(&server).await?;
assert!(!outside_path.starts_with(test.config.cwd.as_path()));
assert_active_workspace_permission_profile(&test);
let call_id = "allowed-escalated-shell-inherits-permission-profile";
let command = format!("/bin/sh {script_path:?}");
let event = shell_event(
call_id,
&command,
/*timeout_ms*/ 5_000,
SandboxPermissions::RequireEscalated,
)?;
let _ = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-escalated-profile-1"),
event,
ev_completed("resp-escalated-profile-1"),
]),
)
.await;
let results = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-escalated-profile-1", "done"),
ev_completed("resp-escalated-profile-2"),
]),
)
.await;
submit_turn_preserving_active_permission_profile(
&test,
"run the allowed script with escalated permissions",
approval_policy,
)
.await?;
wait_for_completion_without_approval(&test).await;
let result = parse_result(&results.single_request().function_call_output(call_id));
assert_eq!(
result,
CommandResult {
exit_code: Some(0),
stdout: format!("{BUILT_IN_PERMISSION_PROFILE_WORKSPACE}\n"),
},
"the unsandboxed script should inherit CODEX_PERMISSION_PROFILE from the shell command"
);
assert!(
outside_path.exists(),
"allowed escalated script should run outside the :workspace sandbox"
);
Ok(())
}
/// Verifies that zsh-fork applies an inner script's allow rule even when the
/// model invokes an outer wrapper, and that the escalated script retains the
/// named profile needed to reconstruct the original sandbox remotely without
/// dropping managed enterprise requirements. The script treats the inherited
/// environment value as untrusted and accepts only explicitly allowlisted
/// profile names before passing one to `codex sandbox -P`.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[cfg(unix)]
async fn zsh_fork_inner_allowed_script_inherits_active_permission_profile() -> Result<()> {
skip_if_no_network!(Ok(()));
let Some(runtime) = zsh_fork_runtime("zsh-fork remote sandbox wrapper test")? else {
return Ok(());
};
const HOST: &str = "builder.example.com";
let approval_policy = AskForApproval::OnRequest;
let script_dir = tempfile::tempdir_in(std::env::current_dir()?)?;
let wrapper_path = script_dir.path().join("remote-bash");
let remote_bash_path = script_dir.path().join("remote_bash.py");
let outside_path = script_dir.path().join("remote-bash-unsandboxed-marker");
let outside_path_literal = serde_json::to_string(&outside_path.to_string_lossy())?;
fs::write(
&remote_bash_path,
format!(
r#"#!/usr/bin/env python3
import argparse
import os
from pathlib import Path
import re
import shlex
import sys
ALLOWED_HOSTS = ("builder.example.com",)
ALLOWED_PROFILES = (":workspace",)
def parse_args():
parser = argparse.ArgumentParser(
description="Print an ssh command that recreates the current Codex sandbox remotely."
)
parser.add_argument("--host", required=True)
try:
separator = sys.argv.index("--")
except ValueError:
parser.error("the remote command must follow --")
args = parser.parse_args(sys.argv[1:separator])
args.command = sys.argv[separator + 1:]
if not args.command:
parser.error("the remote command must not be empty")
if not re.fullmatch(r"[a-z0-9.-]+", args.host) or args.host not in ALLOWED_HOSTS:
parser.error("host is not allowlisted")
return args
def main():
args = parse_args()
profile_name = os.environ.get("CODEX_PERMISSION_PROFILE")
if not profile_name:
raise SystemExit("CODEX_PERMISSION_PROFILE must not be empty")
if profile_name not in ALLOWED_PROFILES:
raise SystemExit("CODEX_PERMISSION_PROFILE is not allowlisted")
shell_command = shlex.join(args.command)
sandbox_command = shlex.join(
[
"codex",
"sandbox",
"-P",
profile_name,
"--include-managed-config",
"--",
"bash",
"-lc",
shell_command,
]
)
print(shlex.join(["ssh", args.host, sandbox_command]))
# Test-only proof that this inner script was allowed to run unsandboxed.
Path({outside_path_literal}).write_text("unsandboxed", encoding="utf-8")
if __name__ == "__main__":
main()
"#
),
)?;
let remote_bash_exec = shlex::try_join([remote_bash_path.to_string_lossy().as_ref()])?;
fs::write(
&wrapper_path,
format!(
r#"#!/usr/bin/env zsh
exec {remote_bash_exec} "$@"
"#
),
)?;
for path in [&wrapper_path, &remote_bash_path] {
let mut permissions = fs::metadata(path)?.permissions();
permissions.set_mode(0o755);
fs::set_permissions(path, permissions)?;
}
let remote_bash_pattern = serde_json::to_string(&remote_bash_path.to_string_lossy())?;
let rules = format!(r#"prefix_rule(pattern=[{remote_bash_pattern}], decision="allow")"#);
let server = start_mock_server().await;
let mut builder =
zsh_fork_test_builder(runtime, approval_policy).with_pre_build_hook(move |home| {
fs::write(
home.join("config.toml"),
WORKSPACE_PERMISSION_PROFILE_CONFIG,
)
.expect("write config");
let rules_dir = home.join("rules");
fs::create_dir_all(&rules_dir).expect("create rules dir");
fs::write(rules_dir.join("default.rules"), rules).expect("write rules");
});
let test = builder.build(&server).await?;
assert!(!outside_path.starts_with(test.config.cwd.as_path()));
assert_active_workspace_permission_profile(&test);
let command = shlex::try_join([
wrapper_path.to_string_lossy().as_ref(),
"--host",
HOST,
"--",
"printf",
"%s",
"hello world",
])?;
let call_id = "zsh-fork-remote-sandbox-wrapper";
let event = shell_event(
call_id,
&command,
/*timeout_ms*/ 30_000,
SandboxPermissions::UseDefault,
)?;
let _ = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-zsh-fork-remote-wrapper-1"),
event,
ev_completed("resp-zsh-fork-remote-wrapper-1"),
]),
)
.await;
let results = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-zsh-fork-remote-wrapper-1", "done"),
ev_completed("resp-zsh-fork-remote-wrapper-2"),
]),
)
.await;
submit_turn_preserving_active_permission_profile(
&test,
"run the remote sandbox wrapper",
approval_policy,
)
.await?;
wait_for_completion_without_approval(&test).await;
let result = parse_result(&results.single_request().function_call_output(call_id));
assert_eq!(
result.exit_code.unwrap_or(0),
0,
"the inner remote_bash.py script should run successfully: {}",
result.stdout
);
let ssh_argv = shlex::split(result.stdout.trim()).context("parse printed ssh command")?;
assert_eq!(
ssh_argv.len(),
3,
"expected ssh HOST LONG_COMMAND, got: {}",
result.stdout
);
assert_eq!(
ssh_argv[..2],
["ssh", HOST],
"remote_bash.py should target only the allowlisted host"
);
let sandbox_argv = shlex::split(&ssh_argv[2]).context("parse remote sandbox command")?;
assert_eq!(
sandbox_argv.len(),
9,
"expected codex sandbox ... bash -lc CMD"
);
assert_eq!(
sandbox_argv[..8],
[
"codex",
"sandbox",
"-P",
BUILT_IN_PERMISSION_PROFILE_WORKSPACE,
"--include-managed-config",
"--",
"bash",
"-lc",
],
"remote_bash.py should use the allowlisted inherited profile and managed configuration to reconstruct the Codex sandbox"
);
let command_argv = shlex::split(&sandbox_argv[8]).context("parse remote bash command")?;
assert_eq!(
command_argv,
["printf", "%s", "hello world"],
"remote_bash.py should preserve every argument after --"
);
assert!(
outside_path.exists(),
"the inner allowlisted script should run outside the :workspace sandbox"
);
Ok(())
}
#[tokio::test(flavor = "current_thread")]
#[cfg(unix)]
async fn invalid_requested_prefix_rule_falls_back_for_compound_command() -> Result<()> {