Skip to content

Bug Report: PATH ordering is mutated when Codex shells launch via bash -lc #4210

Open

Description

@jessfraz

Summary

Codex rewrites every shell tool call into bash -lc "". On macOS (and any system where /etc/profile invokes path_helper), that login shell rebuilds PATH and prepends system entries, so the user’s PATH ordering is lost before their command runs. The mutation breaks toolchains that rely on PATH priority—e.g., Nix-provided cargo shims fall behind Apple’s toolchain, yielding missing libxml2 when Codex replays cargo clippy while the same command succeeds in the user’s terminal.

Impact

  • Nix-managed toolchains, language shims, or custom PATH prefixes stop working inside Codex even though they work locally.
  • PATH order is globally scrambled, so any expectation about “my bin directory wins” fails, independent of macOS.
  • Commands that implicitly depend on PATH (dynamic linker lookup, plugin discovery, wrapper scripts) exhibit inconsistent behavior between Codex and a real terminal.

Environment

  • macOS 14.6.1 (Apple Silicon)
  • Shared Nix shell
  • User login shell: /bin/zsh
  • Codex CLI default shell translation logic (Shell::Bash, use_profile = true)

Steps to Reproduce

  1. In a regular terminal:
export PATH="/tmp/test-bin:/usr/bin"
echo "Before:  $PATH"
/bin/bash -lc 'echo "bash -lc: $PATH"'
bin/bash -c  'echo "bash -c:  $PATH"'
/bin/zsh  -lc 'echo "zsh -lc:  $PATH"'
  1. Observed output on 2025-09-25:
Before:  /tmp/test-bin:/usr/bin
bash -lc: /usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/Applications/Little Snitch.app/Contents/Components:/tmp/test-bin
bash -c:  /tmp/test-bin:/usr/bin
zsh -lc:  /tmp/test-bin:/usr/bin
  1. Trigger the same command through Codex’s shell tool; the PATH matches the bash -lc case above.

Observed vs. Expected

  • Observed: Codex’s login bash shell prepends Apple system paths in front of the caller’s PATH, demoting Nix and user prefixes.
  • Expected: Codex should preserve the exact PATH order supplied in ExecParams.env, matching the caller’s environment.

Root Cause

  • Shell::format_default_shell_invocation always produces [shell_path, "-lc", <script>], forcing a login shell even when the model already generated bash -lc. (core/src/shell.rs) core/src/shell.rs#L33-L135 (
    pub fn format_default_shell_invocation(&self, command: Vec<String>) -> Option<Vec<String>> {
    match self {
    Shell::Zsh(zsh) => format_shell_invocation_with_rc(
    command.as_slice(),
    &zsh.shell_path,
    &zsh.zshrc_path,
    ),
    Shell::Bash(bash) => format_shell_invocation_with_rc(
    command.as_slice(),
    &bash.shell_path,
    &bash.bashrc_path,
    ),
    Shell::PowerShell(ps) => {
    // If model generated a bash command, prefer a detected bash fallback
    if let Some(script) = strip_bash_lc(command.as_slice()) {
    return match &ps.bash_exe_fallback {
    Some(bash) => Some(vec![
    bash.to_string_lossy().to_string(),
    "-lc".to_string(),
    script,
    ]),
    // No bash fallback → run the script under PowerShell.
    // It will likely fail (except for some simple commands), but the error
    // should give a clue to the model to fix upon retry that it's running under PowerShell.
    None => Some(vec![
    ps.exe.clone(),
    "-NoProfile".to_string(),
    "-Command".to_string(),
    script,
    ]),
    };
    }
    // Not a bash command. If model did not generate a PowerShell command,
    // turn it into a PowerShell command.
    let first = command.first().map(String::as_str);
    if first != Some(ps.exe.as_str()) {
    // TODO (CODEX_2900): Handle escaping newlines.
    if command.iter().any(|a| a.contains('\n') || a.contains('\r')) {
    return Some(command);
    }
    let joined = shlex::try_join(command.iter().map(String::as_str)).ok();
    return joined.map(|arg| {
    vec![
    ps.exe.clone(),
    "-NoProfile".to_string(),
    "-Command".to_string(),
    arg,
    ]
    });
    }
    // Model generated a PowerShell command. Run it.
    Some(command)
    }
    Shell::Unknown => None,
    }
    }
    pub fn name(&self) -> Option<String> {
    match self {
    Shell::Zsh(zsh) => std::path::Path::new(&zsh.shell_path)
    .file_name()
    .map(|s| s.to_string_lossy().to_string()),
    Shell::Bash(bash) => std::path::Path::new(&bash.shell_path)
    .file_name()
    .map(|s| s.to_string_lossy().to_string()),
    Shell::PowerShell(ps) => Some(ps.exe.clone()),
    Shell::Unknown => None,
    }
    }
    }
    fn format_shell_invocation_with_rc(
    command: &[String],
    shell_path: &str,
    rc_path: &str,
    ) -> Option<Vec<String>> {
    let joined = strip_bash_lc(command)
    .or_else(|| shlex::try_join(command.iter().map(String::as_str)).ok())?;
    let rc_command = if std::path::Path::new(rc_path).exists() {
    format!("source {rc_path} && ({joined})")
    } else {
    joined
    };
    Some(vec![shell_path.to_string(), "-lc".to_string(), rc_command])
    }
    fn strip_bash_lc(command: &[String]) -> Option<String> {
    match command {
    // exactly three items
    [first, second, third]
    // first two must be "bash", "-lc"
    if first == "bash" && second == "-lc" =>
    {
    Some(third.clone())
    }
    _ => None,
    }
    )
  • maybe_translate_shell_command swaps in that wrapper whenever ShellEnvironmentPolicy.use_profile is true (default on macOS). (core/src/codex.rs) core/src/codex.rs#L2602-L2687 (
    fn to_exec_params(params: ShellToolCallParams, turn_context: &TurnContext) -> ExecParams {
    ExecParams {
    command: params.command,
    cwd: turn_context.resolve_path(params.workdir.clone()),
    timeout_ms: params.timeout_ms,
    env: create_env(&turn_context.shell_environment_policy),
    with_escalated_permissions: params.with_escalated_permissions,
    justification: params.justification,
    }
    }
    fn parse_container_exec_arguments(
    arguments: String,
    turn_context: &TurnContext,
    _call_id: &str,
    ) -> Result<ExecParams, FunctionCallError> {
    serde_json::from_str::<ShellToolCallParams>(&arguments)
    .map(|p| to_exec_params(p, turn_context))
    .map_err(|e| {
    FunctionCallError::RespondToModel(format!("failed to parse function arguments: {e:?}"))
    })
    }
    pub struct ExecInvokeArgs<'a> {
    pub params: ExecParams,
    pub sandbox_type: SandboxType,
    pub sandbox_policy: &'a SandboxPolicy,
    pub sandbox_cwd: &'a Path,
    pub codex_linux_sandbox_exe: &'a Option<PathBuf>,
    pub stdout_stream: Option<StdoutStream>,
    }
    fn maybe_translate_shell_command(
    params: ExecParams,
    sess: &Session,
    turn_context: &TurnContext,
    ) -> ExecParams {
    let should_translate = matches!(sess.user_shell, crate::shell::Shell::PowerShell(_))
    || turn_context.shell_environment_policy.use_profile;
    if should_translate
    && let Some(command) = sess
    .user_shell
    .format_default_shell_invocation(params.command.clone())
    {
    return ExecParams { command, ..params };
    }
    params
    }
    async fn handle_container_exec_with_params(
    params: ExecParams,
    sess: &Session,
    turn_context: &TurnContext,
    turn_diff_tracker: &mut TurnDiffTracker,
    sub_id: String,
    call_id: String,
    ) -> Result<String, FunctionCallError> {
    if params.with_escalated_permissions.unwrap_or(false)
    && !matches!(turn_context.approval_policy, AskForApproval::OnRequest)
    {
    return Err(FunctionCallError::RespondToModel(format!(
    "approval policy is {policy:?}; reject command — you should not ask for escalated permissions if the approval policy is {policy:?}",
    policy = turn_context.approval_policy
    )));
    }
    // check if this was a patch, and apply it if so
    let apply_patch_exec = match maybe_parse_apply_patch_verified(&params.command, &params.cwd) {
    MaybeApplyPatchVerified::Body(changes) => {
    match apply_patch::apply_patch(sess, turn_context, &sub_id, &call_id, changes).await {
    InternalApplyPatchInvocation::Output(item) => return item,
    InternalApplyPatchInvocation::DelegateToExec(apply_patch_exec) => {
    Some(apply_patch_exec)
    }
    }
    }
    MaybeApplyPatchVerified::CorrectnessError(parse_error) => {
    // It looks like an invocation of `apply_patch`, but we
    // could not resolve it into a patch that would apply
    // cleanly. Return to model for resample.
    return Err(FunctionCallError::RespondToModel(format!(
    "error: {parse_error:#?}"
    )));
    }
    MaybeApplyPatchVerified::ShellParseError(error) => {
    )
  • The child process is spawned with a clean environment that Codex repopulates, so PATH enters exactly as provided before bash login hooks run. (core/src/spawn.rs, core/src/exec_env.rs) core/src/spawn.rs#L38-L107 (
    pub(crate) async fn spawn_child_async(
    program: PathBuf,
    args: Vec<String>,
    #[cfg_attr(not(unix), allow(unused_variables))] arg0: Option<&str>,
    cwd: PathBuf,
    sandbox_policy: &SandboxPolicy,
    stdio_policy: StdioPolicy,
    env: HashMap<String, String>,
    ) -> std::io::Result<Child> {
    trace!(
    "spawn_child_async: {program:?} {args:?} {arg0:?} {cwd:?} {sandbox_policy:?} {stdio_policy:?} {env:?}"
    );
    let mut cmd = Command::new(&program);
    #[cfg(unix)]
    cmd.arg0(arg0.map_or_else(|| program.to_string_lossy().to_string(), String::from));
    cmd.args(args);
    cmd.current_dir(cwd);
    cmd.env_clear();
    cmd.envs(env);
    if !sandbox_policy.has_full_network_access() {
    cmd.env(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR, "1");
    }
    // If this Codex process dies (including being killed via SIGKILL), we want
    // any child processes that were spawned as part of a `"shell"` tool call
    // to also be terminated.
    // This relies on prctl(2), so it only works on Linux.
    #[cfg(target_os = "linux")]
    unsafe {
    cmd.pre_exec(|| {
    // This prctl call effectively requests, "deliver SIGTERM when my
    // current parent dies."
    if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) == -1 {
    return Err(std::io::Error::last_os_error());
    }
    // Though if there was a race condition and this pre_exec() block is
    // run _after_ the parent (i.e., the Codex process) has already
    // exited, then the parent is the _init_ process (which will never
    // die), so we should just terminate the child process now.
    if libc::getppid() == 1 {
    libc::raise(libc::SIGTERM);
    }
    Ok(())
    });
    }
    match stdio_policy {
    StdioPolicy::RedirectForShellTool => {
    // Do not create a file descriptor for stdin because otherwise some
    // commands may hang forever waiting for input. For example, ripgrep has
    // a heuristic where it may try to read from stdin as explained here:
    // https://github.com/BurntSushi/ripgrep/blob/e2362d4d5185d02fa857bf381e7bd52e66fafc73/crates/core/flags/hiargs.rs#L1101-L1103
    cmd.stdin(Stdio::null());
    cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
    }
    StdioPolicy::Inherit => {
    // Inherit stdin, stdout, and stderr from the parent process.
    cmd.stdin(Stdio::inherit())
    .stdout(Stdio::inherit())
    .stderr(Stdio::inherit());
    }
    }
    cmd.kill_on_drop(true).spawn()
    }
    ) core/src/exec_env.rs#L14-L68 (
    pub fn create_env(policy: &ShellEnvironmentPolicy) -> HashMap<String, String> {
    populate_env(std::env::vars(), policy)
    }
    fn populate_env<I>(vars: I, policy: &ShellEnvironmentPolicy) -> 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 => {
    const CORE_VARS: &[&str] = &[
    "HOME", "LOGNAME", "PATH", "SHELL", "USER", "USERNAME", "TMPDIR", "TEMP", "TMP",
    ];
    let allow: HashSet<&str> = CORE_VARS.iter().copied().collect();
    vars.into_iter()
    .filter(|(k, _)| allow.contains(k.as_str()))
    .collect()
    }
    };
    // Internal helper – does `name` match **any** pattern in `patterns`?
    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 {
    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));
    }
    env_map
    )
  • GNU Bash treats -l / --login as a login shell and executes /etc/profile before the user command. GNU Bash manual (https://www.gnu.org/software/bash/manual/html_node/Bash-Startup-Files.html)
  • On macOS, /etc/profile runs /usr/libexec/path_helper -s, which rebuilds PATH from /etc/paths and /etc/paths.d, then appends the previous PATH value—earlier entries get pushed behind the system defaults. path_helper man page (https://www.unix.com/man-page/osx/8/path_helper/) path_helper behavior write-up (https://scriptingosx.com/2017/05/path_helper-and-launchctl-setenv/)

Suggested Fixes

  1. Preferred: Stop forcing login shells by default. Use bash -c unless the user explicitly asked for profiles (e.g., use_profile). That preserves PATH while respecting existing behavior for users who opt in.
  2. Fallback: If login shells must stay, restore the original PATH ordering immediately before executing the user script (e.g., inject export PATH='$ORIGINAL' into the generated command). Risky and brittle.
  3. Regression Test: Add an integration test that runs through the Codex shell path with a sentinel PATH (/tmp/a:/tmp/b), captures echo $PATH inside the spawned process, and asserts exact equality.

Supporting Docs

  • GNU Bash manual (login shells read /etc/profile).
  • macOS path_helper man page (path_helper rebuilds PATH from /etc/paths*).
  • External analysis of path_helper ordering behavior.

Activity

  1. jessfraz commented on Sep 25, 2025

    @jessfraz
    Author

    also if you tell me what you want to do here, i will do it, but I don't know the history of "why login shells" so i don't feel comfortable scorching them from the earth without knowing if there was a good reason, even tho i can't personally think of one, i don't have all the context.

  2. gcp commented on Sep 30, 2025

    @gcp

    Similar to #2542.

  3. maruel commented on Sep 30, 2025

    @maruel

    Even on linux in a docker container purely using bash, PATH is modified in surprising way:

    $ echo $PATH
    /home/user/.local/bin:/home/user/.nvm/versions/node/v24.9.0/bin:/usr/local/go/bin:/home/user/go/bin:/usr/local/bin:/usr/bin:/bin:/usr/
    local/games:/usr/games
    
    $ codex
    > run 'echo $PATH'
     Ran echo $PATH
      └ /home/user/.local/bin:/usr/local/go/bin:/home/user/go/bin:/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games
    
    > PATH is /home/user/.local/bin:/usr/local/go/bin:/home/user/go/bin:/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games.
    

    In my .bashrc, I have:

    export NVM_DIR="$HOME/.nvm"
    [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
    

    yet it's not there.

  4. gcp commented on Sep 30, 2025

    @gcp

    In my .bashrc, I have:

    A login shell will never read .bashrc

  5. azarenkovgd commented on Oct 9, 2025

    @azarenkovgd

    Are there any estimates when this bug will be fixed? It severely affects my workflow and forces to use some messy workarounds.

    Maybe there are some official recommendations what to do in this case until the bug is fixed.

  6. ianb commented on Oct 21, 2025

    @ianb

    Note this breaks something as simple as nvm, which modifies the $PATH to set the node interpreter. nvm use also doesn't work within Codex (presumably because environment changes aren't persisted between commands in codex). You can work around this by telling codex to use the absolute path to whatever command or interpreter you want (e.g., (e.g., /Users/username/.nvm/versions/node/v25.0.0/bin/npm), but it's awkward and codex tends to forget to do this.

  7. nachoal commented on Nov 22, 2025

    @nachoal

    This is such a basic thing to get right, is there some context on why you use login shells? Extremely disruptive to workflow, part of the reason why claude code is amazing at tooling an cli work (they get bash execution right)

  8. imajes commented on Nov 25, 2025

    @imajes

    Thanks for writing this up @jessfraz -- really does feel like there's not a clear sense of whether or not a user's shell + env is particularly respected.

    @etraut-openai: I promise I won't keep tagging you in, but you have been doing Sisyphean work in keeping up with PRs/issues. This one matters though. it seems impossible now to say, "i use zsh, please don't create a new shell every time to run your commands?"

    On top of that- it's now cd ... into the folder it's already in every time. is it not keeping track of cwd ?

  9. JaysonGeng commented on Mar 31, 2026

    @JaysonGeng

    I dug through the current code path and put together a targeted fix on my fork:

    Ready branch:
    https://github.com/JaysonGeng/codex/tree/fix/shell-path-order-preservation

    Compare:
    JaysonGeng/codex@main...fix/shell-path-order-preservation

    Commit:
    JaysonGeng@c5524e5

    What it changes:

    • default shell_command / exec_command to non-login shells when login is omitted
    • keep allow_login_shell = true as an explicit opt-in gate for login = true
    • stop /shell (user_shell) from hardcoding login-shell execution
    • keep memories/usage aligned with the new command shape
    • update tool/config schema text so model guidance prefers bash -c over bash -lc

    Validation:

    • cargo test -p codex-tools passed
    • just fix -p codex-core passed
    • just fix -p codex-tools passed
    • just fmt passed
    • cargo test/check -p codex-core under a 60s hard-timeout repeatedly reached the codex-core test/check target without surfacing compile or test failures, but did not fully complete within the timeout on this machine
    • just argument-comment-lint could not run here because the local environment is missing Bazel and tools/argument-comment-lint/*

    If maintainers are interested, I can rebase or split this further, but this should already be reviewable as-is.

  10. JaysonGeng commented on Mar 31, 2026

    @JaysonGeng

    Per the current contributing policy in docs/contributing.md, I am intentionally not opening an unsolicited upstream PR for this.

    The branch and commit are already in a maintainer-ready state:

    If this approach matches the intended direction, I can open the PR immediately once a maintainer invites it; otherwise feel free to cherry-pick c5524e5 directly or ask me to rebase/split it further.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingtool-callsIssues related to tool calling

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions