Files
codex/codex-rs/protocol/src/shell_environment.rs
T
Leonardo Di Donato 9b738582b1 Expose originating tool call metadata in command environments (#52119)
## Why

Command processes need thread and tool call labels for correlation, without stale values from inherited environments or cached shell snapshots.

## What changed

- Export the originating `exec_command` call ID as `CODEX_TOOL_CALL_ID`; later `write_stdin` calls leave it unchanged.
- Apply executor metadata as `CODEX_THREAD_ID` and `CODEX_TOOL_CALL_ID` after environment policies, overlays, and snapshot replay. Clear omitted fields when metadata is present; without metadata, preserve thread context and remove the call ID.
- Exclude both IDs from newly captured snapshots and omit the call ID from capture and validation helpers.
- Preserve empty call IDs, omit NUL-containing IDs, and handle case-insensitive environment names on Windows. Document that these labels support correlation, not authorization.

## Testing

Add regression coverage for metadata precedence, missing and invalid IDs, snapshot capture and replay, local and remote execution with pipes and TTYs, sandboxed remote execution, and preservation of the originating call ID across later input.

GitOrigin-RevId: 1027212b5d495d8e7cfac0f498c3134e060aa897
2026-10-08 13:45:19 +00:00

338 lines
11 KiB
Rust

use crate::config_types::EnvironmentVariablePattern;
use crate::config_types::ShellEnvironmentPolicy;
use crate::config_types::ShellEnvironmentPolicyInherit;
use std::collections::HashMap;
pub const CODEX_SESSION_ID_ENV_VAR: &str = "CODEX_SESSION_ID";
pub const CODEX_THREAD_ID_ENV_VAR: &str = "CODEX_THREAD_ID";
pub const CODEX_TOOL_CALL_ID_ENV_VAR: &str = "CODEX_TOOL_CALL_ID";
pub const CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR: &str = "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN";
pub const OPENAI_FEDERATION_RULE_ID_ENV_VAR: &str = "OPENAI_FEDERATION_RULE_ID";
pub const OPENAI_IDENTITY_TOKEN_FILE_ENV_VAR: &str = "OPENAI_IDENTITY_TOKEN_FILE";
pub const OPENAI_WORKLOAD_IDENTITY_CONTEXT_ENV_VAR: &str = "OPENAI_WORKLOAD_IDENTITY_CONTEXT";
/// Environment variables that model-reachable child processes must not inherit.
pub const NON_INHERITABLE_ENV_VARS: &[&str] = &[
CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR,
"NODE_REPL_AUTH_TOKEN",
"CODEX_GUARDIAN_DECISIONS_API_KEY",
OPENAI_FEDERATION_RULE_ID_ENV_VAR,
OPENAI_IDENTITY_TOKEN_FILE_ENV_VAR,
OPENAI_WORKLOAD_IDENTITY_CONTEXT_ENV_VAR,
];
pub fn is_non_inheritable_env_var(name: &str) -> bool {
NON_INHERITABLE_ENV_VARS
.iter()
.any(|restricted| restricted.eq_ignore_ascii_case(name))
}
pub fn set_tool_call_id_env_var(env: &mut HashMap<String, String>, tool_call_id: Option<&str>) {
#[cfg(windows)]
env.retain(|name, _| !name.eq_ignore_ascii_case(CODEX_TOOL_CALL_ID_ENV_VAR));
#[cfg(not(windows))]
env.remove(CODEX_TOOL_CALL_ID_ENV_VAR);
if let Some(tool_call_id) = tool_call_id.filter(|tool_call_id| !tool_call_id.contains('\0')) {
env.insert(
CODEX_TOOL_CALL_ID_ENV_VAR.to_string(),
tool_call_id.to_string(),
);
}
}
/// Configures a child command to omit non-inheritable variables from the
/// process environment and explicit command overrides.
///
/// This prevents accidental propagation of Codex launch context; it is not a
/// filesystem security boundary for the referenced identity-token file.
pub fn scrub_non_inheritable_env_vars(command: &mut std::process::Command) {
let configured_names = command
.get_envs()
.map(|(name, _)| name.to_os_string())
.collect::<Vec<_>>();
for name in NON_INHERITABLE_ENV_VARS {
command.env_remove(name);
}
for name in std::env::vars_os()
.map(|(name, _)| name)
.chain(configured_names)
{
if name.to_str().is_some_and(is_non_inheritable_env_var) {
command.env_remove(name);
}
}
}
/// Construct a shell environment from the supplied process environment and
/// shell-environment policy.
pub fn create_env(
policy: &ShellEnvironmentPolicy,
thread_id: Option<&str>,
) -> HashMap<String, String> {
create_env_from_vars(std::env::vars(), policy, thread_id)
}
pub fn create_env_from_vars<I>(
vars: I,
policy: &ShellEnvironmentPolicy,
thread_id: Option<&str>,
) -> HashMap<String, String>
where
I: IntoIterator<Item = (String, String)>,
{
let mut env_map = populate_env(vars, policy, thread_id);
if cfg!(target_os = "windows") {
// This is a workaround to address the failures we are seeing in the
// following tests when run via Bazel on Windows:
//
// ```
// suite::shell_command::unicode_output::with_login
// suite::shell_command::unicode_output::without_login
// ```
//
// Currently, we can only reproduce these failures in CI, which makes
// iteration times long, so we include this quick fix for now to unblock
// getting the Windows Bazel build running.
if !env_map.keys().any(|k| k.eq_ignore_ascii_case("PATHEXT")) {
env_map.insert("PATHEXT".to_string(), ".COM;.EXE;.BAT;.CMD".to_string());
}
}
env_map
}
pub fn populate_env<I>(
vars: I,
policy: &ShellEnvironmentPolicy,
thread_id: Option<&str>,
) -> HashMap<String, String>
where
I: IntoIterator<Item = (String, String)>,
{
// Step 1 - determine the starting set of variables based on the
// `inherit` strategy.
let mut env_map: HashMap<String, String> = match policy.inherit {
ShellEnvironmentPolicyInherit::All => vars.into_iter().collect(),
ShellEnvironmentPolicyInherit::None => HashMap::new(),
ShellEnvironmentPolicyInherit::Core => {
#[cfg(not(target_os = "windows"))]
let core_env_vars = UNIX_CORE_ENV_VARS;
#[cfg(target_os = "windows")]
let core_env_vars = WINDOWS_CORE_ENV_VARS;
vars.into_iter()
.filter(|(k, _)| {
core_env_vars
.iter()
.any(|allowed| allowed.eq_ignore_ascii_case(k))
})
.collect()
}
};
let matches_any = |name: &str, patterns: &[EnvironmentVariablePattern]| -> bool {
patterns.iter().any(|pattern| pattern.matches(name))
};
// Step 2 - Apply the default exclude if not disabled.
if !policy.ignore_default_excludes {
let default_excludes = vec![
EnvironmentVariablePattern::new_case_insensitive("*KEY*"),
EnvironmentVariablePattern::new_case_insensitive("*SECRET*"),
EnvironmentVariablePattern::new_case_insensitive("*TOKEN*"),
];
env_map.retain(|k, _| !matches_any(k, &default_excludes));
}
// Step 3 - Apply custom excludes.
if !policy.exclude.is_empty() {
env_map.retain(|k, _| !matches_any(k, &policy.exclude));
}
// Step 4 - Apply user-provided overrides.
for (key, val) in &policy.r#set {
#[cfg(windows)]
env_map.retain(|existing, _| !existing.eq_ignore_ascii_case(key));
env_map.insert(key.clone(), val.clone());
}
// Step 5 - If include_only is non-empty, keep only the matching vars.
if !policy.include_only.is_empty() {
env_map.retain(|k, _| matches_any(k, &policy.include_only));
}
// Step 6 - Populate the thread ID environment variable when provided.
if let Some(thread_id) = thread_id {
env_map.insert(CODEX_THREAD_ID_ENV_VAR.to_string(), thread_id.to_string());
}
// Restricted launch context cannot be restored through user-provided shell
// environment overrides.
env_map.retain(|name, _| !is_non_inheritable_env_var(name));
env_map
}
#[cfg(not(target_os = "windows"))]
const UNIX_CORE_ENV_VARS: &[&str] = &[
"PATH", "SHELL", "TMPDIR", "TEMP", "TMP", "HOME", "LANG", "LC_ALL", "LC_CTYPE", "LOGNAME",
"USER",
];
#[cfg(target_os = "windows")]
pub const WINDOWS_CORE_ENV_VARS: &[&str] = &[
// Core path resolution
"PATH",
"PATHEXT",
// Shell and system roots
"SHELL",
"COMSPEC",
"SYSTEMROOT",
"WINDIR",
"SYSTEMDRIVE",
// User context and profiles
"USERNAME",
"USERDOMAIN",
"USERPROFILE",
"HOMEDRIVE",
"HOMEPATH",
// Program locations
"PROGRAMFILES",
"PROGRAMFILES(X86)",
"PROGRAMW6432",
"PROGRAMDATA",
// App data and caches
"LOCALAPPDATA",
"APPDATA",
// Temp locations
"TEMP",
"TMP",
"TMPDIR",
// Common shells/pwsh hints
"POWERSHELL",
"PWSH",
];
#[cfg(test)]
#[path = "shell_environment_tests.rs"]
mod tests;
#[cfg(all(test, target_os = "windows"))]
mod windows_tests {
use super::*;
use pretty_assertions::assert_eq;
fn make_vars(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
pairs
.iter()
.map(|(key, value)| (key.to_string(), value.to_string()))
.collect()
}
#[test]
#[cfg(target_os = "windows")]
fn core_inherit_preserves_windows_startup_vars_case_insensitively() {
let vars = make_vars(&[
("Shell", "C:\\Program Files\\Git\\bin\\bash.exe"),
("SystemRoot", "C:\\Windows"),
("WinDir", "C:\\Windows"),
("AppData", "C:\\Users\\codex\\AppData\\Roaming"),
("TmpDir", "C:\\Temp\\custom"),
("OPENAI_API_KEY", "secret"),
]);
let policy = ShellEnvironmentPolicy {
inherit: ShellEnvironmentPolicyInherit::Core,
ignore_default_excludes: true,
..Default::default()
};
// Check a few sample vars instead of the full Windows core list.
let result = populate_env(vars, &policy, /*thread_id*/ None);
let expected = HashMap::from([
(
"Shell".to_string(),
"C:\\Program Files\\Git\\bin\\bash.exe".to_string(),
),
("SystemRoot".to_string(), "C:\\Windows".to_string()),
("WinDir".to_string(), "C:\\Windows".to_string()),
(
"AppData".to_string(),
"C:\\Users\\codex\\AppData\\Roaming".to_string(),
),
("TmpDir".to_string(), "C:\\Temp\\custom".to_string()),
]);
assert_eq!(result, expected);
let policy = ShellEnvironmentPolicy {
inherit: ShellEnvironmentPolicyInherit::All,
ignore_default_excludes: true,
r#set: HashMap::from([("gh_host".to_string(), "github.trusted.example".to_string())]),
..Default::default()
};
assert_eq!(
populate_env(
make_vars(&[("GH_HOST", "github.stale.example")]),
&policy,
/*thread_id*/ None,
),
HashMap::from([("gh_host".to_string(), "github.trusted.example".to_string(),)]),
);
}
#[test]
#[cfg(target_os = "windows")]
fn create_env_inserts_pathext_on_windows_when_missing() {
let policy = ShellEnvironmentPolicy {
inherit: ShellEnvironmentPolicyInherit::None,
ignore_default_excludes: true,
..Default::default()
};
let result = create_env_from_vars(Vec::new(), &policy, /*thread_id*/ None);
let expected = HashMap::from([("PATHEXT".to_string(), ".COM;.EXE;.BAT;.CMD".to_string())]);
assert_eq!(result, expected);
}
}
#[cfg(all(test, not(target_os = "windows")))]
mod non_windows_tests {
use super::*;
use pretty_assertions::assert_eq;
fn make_vars(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
pairs
.iter()
.map(|(key, value)| (key.to_string(), value.to_string()))
.collect()
}
#[test]
fn core_inherit_preserves_non_windows_core_vars_case_insensitively() {
let vars = make_vars(&[
("path", "/usr/bin"),
("home", "/home/codex"),
("TmpDir", "/tmp/custom"),
("OPENAI_API_KEY", "secret"),
]);
let policy = ShellEnvironmentPolicy {
inherit: ShellEnvironmentPolicyInherit::Core,
ignore_default_excludes: true,
..Default::default()
};
let result = populate_env(vars, &policy, /*thread_id*/ None);
let expected = HashMap::from([
("path".to_string(), "/usr/bin".to_string()),
("home".to_string(), "/home/codex".to_string()),
("TmpDir".to_string(), "/tmp/custom".to_string()),
]);
assert_eq!(result, expected);
}
}