Skip to content

Commit cf7fb2d

Browse files
committed
core: expose permission profile to shell tools
1 parent f15df62 commit cf7fb2d

10 files changed

Lines changed: 635 additions & 29 deletions

File tree

codex-rs/core/src/exec_env.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,16 @@ use codex_protocol::ThreadId;
22
#[cfg(test)]
33
use codex_protocol::config_types::EnvironmentVariablePattern;
44
use codex_protocol::config_types::ShellEnvironmentPolicy;
5+
use codex_protocol::models::ActivePermissionProfile;
56
use codex_protocol::shell_environment;
67
use std::collections::HashMap;
78

89
pub use codex_protocol::shell_environment::CODEX_THREAD_ID_ENV_VAR;
910

11+
/// Informational name of the active permission profile. Child processes can
12+
/// overwrite this value, so it must not be treated as proof of enforcement.
13+
pub const CODEX_PERMISSION_PROFILE_ENV_VAR: &str = "CODEX_PERMISSION_PROFILE";
14+
1015
/// Construct an environment map based on the rules in the specified policy. The
1116
/// resulting map can be passed directly to `Command::envs()` after calling
1217
/// `env_clear()` to ensure no unintended variables are leaked to the spawned
@@ -25,6 +30,27 @@ pub fn create_env(
2530
shell_environment::create_env(policy, thread_id.as_deref())
2631
}
2732

33+
/// Injects the selected named permission profile into a shell tool's environment.
34+
///
35+
/// This is applied after the shell environment policy so the runtime-selected
36+
/// profile wins over inherited or configured values.
37+
pub(crate) fn inject_permission_profile_env(
38+
env: &mut HashMap<String, String>,
39+
active_permission_profile: Option<&ActivePermissionProfile>,
40+
) {
41+
if cfg!(windows) {
42+
env.retain(|key, _| !key.eq_ignore_ascii_case(CODEX_PERMISSION_PROFILE_ENV_VAR));
43+
} else {
44+
env.remove(CODEX_PERMISSION_PROFILE_ENV_VAR);
45+
}
46+
if let Some(active_permission_profile) = active_permission_profile {
47+
env.insert(
48+
CODEX_PERMISSION_PROFILE_ENV_VAR.to_string(),
49+
active_permission_profile.id.clone(),
50+
);
51+
}
52+
}
53+
2854
#[cfg(all(test, target_os = "windows"))]
2955
fn create_env_from_vars<I>(
3056
vars: I,

codex-rs/core/src/exec_env_tests.rs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,59 @@ fn make_vars(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
1010
.collect()
1111
}
1212

13+
#[test]
14+
fn inject_permission_profile_env_overrides_policy_value() {
15+
let mut env = HashMap::from([(
16+
CODEX_PERMISSION_PROFILE_ENV_VAR.to_string(),
17+
"stale-profile".to_string(),
18+
)]);
19+
20+
inject_permission_profile_env(
21+
&mut env,
22+
Some(&ActivePermissionProfile::new("current-profile")),
23+
);
24+
25+
assert_eq!(
26+
env.get(CODEX_PERMISSION_PROFILE_ENV_VAR)
27+
.map(String::as_str),
28+
Some("current-profile")
29+
);
30+
}
31+
32+
#[test]
33+
fn inject_permission_profile_env_removes_stale_value_without_active_profile() {
34+
let mut env = HashMap::from([(
35+
CODEX_PERMISSION_PROFILE_ENV_VAR.to_string(),
36+
"stale-profile".to_string(),
37+
)]);
38+
39+
inject_permission_profile_env(&mut env, /*active_permission_profile*/ None);
40+
41+
assert_eq!(env.get(CODEX_PERMISSION_PROFILE_ENV_VAR), None);
42+
}
43+
44+
#[cfg(target_os = "windows")]
45+
#[test]
46+
fn inject_permission_profile_env_replaces_differently_cased_windows_key() {
47+
let mut env = HashMap::from([(
48+
"codex_permission_profile".to_string(),
49+
"stale-profile".to_string(),
50+
)]);
51+
52+
inject_permission_profile_env(
53+
&mut env,
54+
Some(&ActivePermissionProfile::new("current-profile")),
55+
);
56+
57+
assert_eq!(
58+
env,
59+
HashMap::from([(
60+
CODEX_PERMISSION_PROFILE_ENV_VAR.to_string(),
61+
"current-profile".to_string(),
62+
)])
63+
);
64+
}
65+
1366
#[test]
1467
fn test_core_inherit_defaults_keep_sensitive_vars() {
1568
let vars = make_vars(&[

codex-rs/core/src/tools/handlers/shell/shell_command.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use codex_utils_absolute_path::AbsolutePathBuf;
66
use crate::exec::ExecCapturePolicy;
77
use crate::exec::ExecParams;
88
use crate::exec_env::create_env;
9+
use crate::exec_env::inject_permission_profile_env;
910
use crate::function_tool::FunctionCallError;
1011
use crate::maybe_emit_implicit_skill_invocation;
1112
use crate::session::turn_context::TurnContext;
@@ -99,15 +100,19 @@ impl ShellCommandHandler {
99100
let use_login_shell = Self::resolve_use_login_shell(params.login, allow_login_shell)?;
100101
let command = Self::base_command(shell, &params.command, use_login_shell);
101102

103+
let mut env = create_env(
104+
&turn_context.config.permissions.shell_environment_policy,
105+
Some(session.thread_id),
106+
);
107+
let active_permission_profile = turn_context.config.permissions.active_permission_profile();
108+
inject_permission_profile_env(&mut env, active_permission_profile.as_ref());
109+
102110
Ok(ExecParams {
103111
command,
104112
cwd,
105113
expiration: params.timeout_ms.into(),
106114
capture_policy: ExecCapturePolicy::ShellTool,
107-
env: create_env(
108-
&turn_context.config.permissions.shell_environment_policy,
109-
Some(session.thread_id),
110-
),
115+
env,
111116
network: turn_context.network.clone(),
112117
network_environment_id: Some(turn_environment.environment_id.clone()),
113118
sandbox_permissions: params.sandbox_permissions.unwrap_or_default(),

codex-rs/core/src/tools/handlers/shell_tests.rs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
use std::path::PathBuf;
22
use std::sync::Arc;
33

4+
use codex_protocol::models::ActivePermissionProfile;
45
use codex_protocol::models::ShellCommandToolCallParams;
56
use pretty_assertions::assert_eq;
67

8+
use crate::config::PermissionProfileSnapshot;
9+
use crate::exec_env::CODEX_PERMISSION_PROFILE_ENV_VAR;
710
use crate::exec_env::create_env;
11+
use crate::exec_env::inject_permission_profile_env;
812
use crate::sandboxing::SandboxPermissions;
913
use crate::session::step_context::StepContext;
1014
use crate::session::tests::make_session_and_context;
@@ -71,7 +75,15 @@ fn assert_safe(shell: &Shell, command: &str) {
7175

7276
#[tokio::test]
7377
async fn shell_command_handler_to_exec_params_uses_selected_environment() {
74-
let (session, turn_context) = make_session_and_context().await;
78+
let (session, mut turn_context) = make_session_and_context().await;
79+
let permission_profile = turn_context.config.permissions.permission_profile().clone();
80+
Arc::make_mut(&mut turn_context.config)
81+
.permissions
82+
.set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active(
83+
permission_profile,
84+
ActivePermissionProfile::new("test-profile"),
85+
))
86+
.expect("set active permission profile");
7587

7688
let command = "echo hello".to_string();
7789
let workdir = Some("subdir".to_string());
@@ -99,10 +111,12 @@ async fn shell_command_handler_to_exec_params_uses_selected_environment() {
99111
PathUri::from_abs_path(&selected_cwd),
100112
Some(selected_shell),
101113
);
102-
let expected_env = create_env(
114+
let mut expected_env = create_env(
103115
&turn_context.config.permissions.shell_environment_policy,
104116
Some(session.thread_id),
105117
);
118+
let active_permission_profile = turn_context.config.permissions.active_permission_profile();
119+
inject_permission_profile_env(&mut expected_env, active_permission_profile.as_ref());
106120

107121
let params = ShellCommandToolCallParams {
108122
command,
@@ -129,6 +143,12 @@ async fn shell_command_handler_to_exec_params_uses_selected_environment() {
129143
assert_eq!(exec_params.command, expected_command);
130144
assert_eq!(exec_params.cwd, expected_cwd);
131145
assert_eq!(exec_params.env, expected_env);
146+
assert_eq!(
147+
exec_params.env.get(CODEX_PERMISSION_PROFILE_ENV_VAR),
148+
active_permission_profile
149+
.as_ref()
150+
.map(|profile| &profile.id)
151+
);
132152
assert_eq!(exec_params.network, turn_context.network);
133153
assert_eq!(
134154
exec_params.network_environment_id.as_deref(),

codex-rs/core/src/tools/runtimes/mod.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ Module: runtimes
44
Concrete ToolRuntime implementations for specific tools. Each runtime stays
55
small and focused and reuses the orchestrator for approvals + sandbox + retry.
66
*/
7+
use crate::exec_env::CODEX_PERMISSION_PROFILE_ENV_VAR;
78
use crate::exec_env::CODEX_THREAD_ID_ENV_VAR;
89
use crate::sandboxing::SandboxPermissions;
910
use crate::shell::Shell;
@@ -287,8 +288,10 @@ pub(crate) fn maybe_wrap_shell_lc_with_snapshot(
287288
.map(|arg| format!(" '{}'", shell_single_quote(arg)))
288289
.collect::<String>();
289290
let mut override_env = explicit_env_overrides.clone();
290-
if let Some(thread_id) = env.get(CODEX_THREAD_ID_ENV_VAR) {
291-
override_env.insert(CODEX_THREAD_ID_ENV_VAR.to_string(), thread_id.clone());
291+
for key in [CODEX_THREAD_ID_ENV_VAR, CODEX_PERMISSION_PROFILE_ENV_VAR] {
292+
if let Some(value) = env.get(key) {
293+
override_env.insert(key.to_string(), value.clone());
294+
}
292295
}
293296
let (override_captures, override_exports) = build_override_exports(&override_env);
294297
let (proxy_captures, proxy_exports) = build_proxy_env_exports();
@@ -319,7 +322,9 @@ fn build_override_exports(explicit_env_overrides: &HashMap<String, String>) -> (
319322
.map(String::as_str)
320323
.filter(|key| is_valid_shell_variable_name(key))
321324
.collect::<Vec<_>>();
325+
keys.push(CODEX_PERMISSION_PROFILE_ENV_VAR);
322326
keys.sort_unstable();
327+
keys.dedup();
323328

324329
build_override_exports_for_keys("__CODEX_SNAPSHOT_OVERRIDE", &keys)
325330
}

codex-rs/core/src/tools/runtimes/mod_tests.rs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,78 @@ fn maybe_wrap_shell_lc_with_snapshot_restores_codex_thread_id_from_env() {
534534
assert_eq!(String::from_utf8_lossy(&output.stdout), "nested-thread");
535535
}
536536

537+
#[test]
538+
fn maybe_wrap_shell_lc_with_snapshot_restores_permission_profile_from_env() {
539+
let dir = tempdir().expect("create temp dir");
540+
let snapshot_path = dir.path().join("snapshot.sh");
541+
std::fs::write(
542+
&snapshot_path,
543+
"# Snapshot file\nexport CODEX_PERMISSION_PROFILE='parent-profile'\n",
544+
)
545+
.expect("write snapshot");
546+
let (session_shell, shell_snapshot) =
547+
shell_with_snapshot(ShellType::Bash, "/bin/bash", snapshot_path.abs());
548+
let command = vec![
549+
"/bin/bash".to_string(),
550+
"-lc".to_string(),
551+
"printenv CODEX_PERMISSION_PROFILE".to_string(),
552+
];
553+
let env = HashMap::from([(
554+
CODEX_PERMISSION_PROFILE_ENV_VAR.to_string(),
555+
"current-profile".to_string(),
556+
)]);
557+
let rewritten = maybe_wrap_shell_lc_with_snapshot(
558+
&command,
559+
&session_shell,
560+
Some(&shell_snapshot),
561+
&HashMap::new(),
562+
&env,
563+
&RuntimePathPrepends::default(),
564+
);
565+
let output = Command::new(&rewritten[0])
566+
.args(&rewritten[1..])
567+
.env(CODEX_PERMISSION_PROFILE_ENV_VAR, "current-profile")
568+
.output()
569+
.expect("run rewritten command");
570+
571+
assert!(output.status.success(), "command failed: {output:?}");
572+
assert_eq!(String::from_utf8_lossy(&output.stdout), "current-profile\n");
573+
}
574+
575+
#[test]
576+
fn maybe_wrap_shell_lc_with_snapshot_unsets_absent_permission_profile() {
577+
let dir = tempdir().expect("create temp dir");
578+
let snapshot_path = dir.path().join("snapshot.sh");
579+
std::fs::write(
580+
&snapshot_path,
581+
"# Snapshot file\nexport CODEX_PERMISSION_PROFILE='stale-profile'\n",
582+
)
583+
.expect("write snapshot");
584+
let (session_shell, shell_snapshot) =
585+
shell_with_snapshot(ShellType::Bash, "/bin/bash", snapshot_path.abs());
586+
let command = vec![
587+
"/bin/bash".to_string(),
588+
"-lc".to_string(),
589+
"printenv CODEX_PERMISSION_PROFILE".to_string(),
590+
];
591+
let rewritten = maybe_wrap_shell_lc_with_snapshot(
592+
&command,
593+
&session_shell,
594+
Some(&shell_snapshot),
595+
&HashMap::new(),
596+
&HashMap::new(),
597+
&RuntimePathPrepends::default(),
598+
);
599+
let output = Command::new(&rewritten[0])
600+
.args(&rewritten[1..])
601+
.env_remove(CODEX_PERMISSION_PROFILE_ENV_VAR)
602+
.output()
603+
.expect("run rewritten command");
604+
605+
assert_eq!(output.status.code(), Some(1));
606+
assert_eq!(output.stdout, b"");
607+
}
608+
537609
#[test]
538610
fn maybe_wrap_shell_lc_with_snapshot_restores_proxy_env_from_process_env() {
539611
let dir = tempdir().expect("create temp dir");

codex-rs/core/src/unified_exec/process_manager.rs

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ use tokio_util::sync::CancellationToken;
1313
use uuid::Uuid;
1414

1515
use crate::codex_thread::BackgroundTerminalInfo;
16+
use crate::exec_env::CODEX_PERMISSION_PROFILE_ENV_VAR;
1617
use crate::exec_env::CODEX_THREAD_ID_ENV_VAR;
1718
use crate::exec_env::create_env;
19+
use crate::exec_env::inject_permission_profile_env;
1820
use crate::exec_policy::ExecApprovalRequest;
1921
use crate::sandboxing::ExecOptions;
2022
use crate::sandboxing::ExecRequest;
@@ -109,15 +111,19 @@ fn apply_unified_exec_env(mut env: HashMap<String, String>) -> HashMap<String, S
109111
fn exec_env_policy_from_shell_policy(
110112
policy: &ShellEnvironmentPolicy,
111113
) -> codex_exec_server::ExecEnvPolicy {
114+
let mut exclude = policy
115+
.exclude
116+
.iter()
117+
.map(std::string::ToString::to_string)
118+
.collect::<Vec<_>>();
119+
exclude.push(CODEX_PERMISSION_PROFILE_ENV_VAR.to_string());
120+
let mut r#set = policy.r#set.clone();
121+
r#set.retain(|key, _| !key.eq_ignore_ascii_case(CODEX_PERMISSION_PROFILE_ENV_VAR));
112122
codex_exec_server::ExecEnvPolicy {
113123
inherit: policy.inherit.clone(),
114124
ignore_default_excludes: policy.ignore_default_excludes,
115-
exclude: policy
116-
.exclude
117-
.iter()
118-
.map(std::string::ToString::to_string)
119-
.collect(),
120-
r#set: policy.r#set.clone(),
125+
exclude,
126+
r#set,
121127
include_only: policy
122128
.include_only
123129
.iter()
@@ -132,7 +138,10 @@ fn env_overlay_for_exec_server(
132138
) -> HashMap<String, String> {
133139
request_env
134140
.iter()
135-
.filter(|(key, value)| local_policy_env.get(*key) != Some(*value))
141+
.filter(|(key, value)| {
142+
key.as_str() == CODEX_PERMISSION_PROFILE_ENV_VAR
143+
|| local_policy_env.get(*key) != Some(*value)
144+
})
136145
.map(|(key, value)| (key.clone(), value.clone()))
137146
.collect()
138147
}
@@ -1110,6 +1119,8 @@ impl UnifiedExecProcessManager {
11101119
CODEX_THREAD_ID_ENV_VAR.to_string(),
11111120
context.session.thread_id.to_string(),
11121121
);
1122+
let active_permission_profile = context.turn.config.permissions.active_permission_profile();
1123+
inject_permission_profile_env(&mut env, active_permission_profile.as_ref());
11131124
let env = apply_unified_exec_env(env);
11141125
let exec_server_env_config = ExecServerEnvConfig {
11151126
policy: exec_env_policy_from_shell_policy(

0 commit comments

Comments
 (0)