Skip to content

fix(exec): resolve wrapper recursion when shims are in PATH#8560

Merged
jdx merged 1 commit intomainfrom
fix/exec-wrapper-recursion-with-shims
Mar 11, 2026
Merged

fix(exec): resolve wrapper recursion when shims are in PATH#8560
jdx merged 1 commit intomainfrom
fix/exec-wrapper-recursion-with-shims

Conversation

@jdx
Copy link
Copy Markdown
Owner

@jdx jdx commented Mar 11, 2026

Summary

  • When both a wrapper directory (e.g. .devcontainer/bin) and the shims directory are in PATH with the wrapper appearing first, mise x -- tool resolved the tool back to the wrapper script instead of the real binary, causing infinite recursion until E2BIG
  • Root cause: PathEnv ordering puts paths before shims (pre) before tool bins (mise), so wrappers are found first. The existing shim-stripping in exec_program only removed the shims directory
  • Fix: pass tool bin paths into exec_program and prepend them to the lookup PATH (used only for binary resolution, not inherited by the child). This ensures the real binary is always found before wrapper scripts
  • Adds e2e/cli/test_exec_wrapper_recursion_with_shims which reproduces the exact scenario (times out without the fix, passes with it)

Context

This was discovered via a Figma devcontainer where .devcontainer/bin/gitleaks (a mise exec wrapper) sat before ~/.mise/shims in PATH. The check-secrets pre-commit hook would infinite-loop ~7 times until the env exceeded ARG_MAX, producing a confusing "gitleaks detected sensitive information" error that was actually an E2BIG.

Test plan

  • New e2e test test_exec_wrapper_recursion_with_shims passes (was timing out before fix)
  • Existing test_exec_wrapper_recursion still passes
  • Existing test_exec_shim_recursion still passes
  • cargo check passes
  • All lints pass (hk check --all)

🤖 Generated with Claude Code


Note

Medium Risk
Changes command resolution ordering on both Unix and Windows, which could affect edge cases where users relied on system PATH taking precedence during mise x lookup. The change is limited to lookup-time PATH manipulation and is covered by a new recursion regression test.

Overview
Prevents mise exec/mise x from infinitely recursing when a wrapper script (e.g. .devcontainer/bin/tool) and the mise shims directory are both on PATH and the wrapper appears first.

Instead of only stripping shims during binary resolution, exec_program now builds a lookup-only PATH that prioritizes mise-added paths (tool bins and _.path) ahead of the original system PATH while still excluding shims; the child process continues to inherit the original PATH. Adds an e2e regression test e2e/cli/test_exec_wrapper_recursion_with_shims that reproduces the devcontainer wrapper+shims ordering scenario.

Written by Cursor Bugbot for commit e71c8a4. This will update automatically on new commits. Configure here.

@gemini-code-assist
Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request addresses a critical recursion bug in mise x -- tool that occurred in specific PATH configurations, particularly in development environments like devcontainers. The issue caused mise to repeatedly execute wrapper scripts instead of the intended binaries, leading to infinite loops and system errors. The solution involves refining the path resolution logic within exec_program to prioritize actual tool binaries over wrapper scripts during execution lookup, thereby preventing the recursion and ensuring correct tool invocation.

Highlights

  • Wrapper Recursion Fix: Resolved an issue where mise x -- tool would cause infinite recursion when a wrapper directory and the shims directory were both in PATH, with the wrapper appearing first.
  • Root Cause Identified: The problem stemmed from PathEnv ordering, which placed wrapper paths before shims, leading to the wrapper script being resolved instead of the actual binary.
  • Path Resolution Enhancement: Implemented a fix by passing tool binary paths into exec_program and prepending them to the lookup PATH, ensuring the real binary is always found before wrapper scripts.
  • New Regression Test: Added a new end-to-end test (e2e/cli/test_exec_wrapper_recursion_with_shims) to specifically reproduce and validate the fix for this recursion scenario.
Changelog
  • e2e/cli/test_exec_wrapper_recursion_with_shims
    • Added a new e2e test to reproduce and verify the fix for the wrapper recursion issue when shims are in PATH.
  • src/cli/exec.rs
    • Modified the exec_program function signature to accept tool_paths.
    • Updated the path resolution logic to prepend tool_paths to the lookup PATH, ensuring real binaries are found before wrapper scripts.
    • Adjusted calls to exec_program to pass the tool_paths argument.
  • src/cli/tool_stub.rs
    • Updated existing calls to exec_program to include an empty tool_paths vector, maintaining compatibility with the new function signature.
Activity
  • New e2e test test_exec_wrapper_recursion_with_shims passes (was timing out before fix).
  • Existing test_exec_wrapper_recursion still passes.
  • Existing test_exec_shim_recursion still passes.
  • cargo check passes.
  • All lints pass (hk check --all).
  • Generated with Claude Code.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 11, 2026

Greptile Summary

This PR fixes an infinite-recursion bug in mise exec that occurred when a wrapper script (e.g. .devcontainer/bin/tool) sat before both the mise shims directory and the real tool binary in PATH. The previous fix only stripped the shims directory from the lookup PATH, but PathEnv ordering meant wrapper scripts in "pre-shims" slots were still found first by which, re-entering the wrapper endlessly until E2BIG.

Key changes:

  • exec_program now partitions the effective PATH into mise-added paths (tool bins, _.path entries—anything not present in the pristine PATH recorded at startup) and original system paths, then builds a lookup PATH as mise_added + (original − shims). This guarantees real tool binaries always win for name resolution while the child process still inherits the full unmodified PATH.
  • The Windows branch applies the same strategy with case-insensitive, backslash-normalized path comparisons.
  • A new e2e regression test (test_exec_wrapper_recursion_with_shims) reproduces the exact devcontainer scenario (wrapper dir → shims dir → rest of PATH) with a 10-second timeout, confirming the fix prevents the hang.

Confidence Score: 4/5

  • Safe to merge; the PATH reordering is logically sound for all described scenarios and the regression test covers the exact edge case.
  • The core logic correctly separates mise-added paths from pristine system paths and places the former first in the resolution PATH, preventing wrappers from being found before real tool bins. The shims directory is properly excluded from lookups in both Unix and Windows paths. Two minor style concerns exist: (1) the shims dir isn't defensively excluded from the mise_added bucket on Unix (safe in practice due to mise's PATH construction order), and (2) the Windows normalization helper closure is duplicated three times. Neither is a correctness issue under current conditions.
  • src/cli/exec.rs — Windows branch normalization duplication and the shims-in-mise_added gap.

Important Files Changed

Filename Overview
src/cli/exec.rs Replaces shim-stripping with a full PATH reordering strategy: mise-added paths come first in the lookup PATH, ensuring real tool binaries are found before wrapper scripts. Windows branch correctly adds case-insensitive normalization but repeats the normalization logic three times. No critical bugs; one minor concern about the shims dir appearing in mise_added if shims were added by mise itself rather than by the user.
e2e/cli/test_exec_wrapper_recursion_with_shims New regression test that reproduces the infinite-recursion scenario: wrapper dir before shims dir in PATH, then calls mise x -- dummy. Uses a 10-second timeout as a safety net and asserts real dummy output, correctly validating the fix.

Sequence Diagram

sequenceDiagram
    participant Shell
    participant WrapperScript as .devcontainer/bin/dummy
    participant MiseExec as mise x -- dummy
    participant ExecProgram as exec_program()
    participant Which as which::which_in()
    participant RealBinary as ~/.mise/installs/dummy/.../dummy

    Shell->>WrapperScript: exec dummy
    WrapperScript->>MiseExec: exec mise x -- dummy
    MiseExec->>ExecProgram: program="dummy", env with PATH

    Note over ExecProgram: Build lookup_path
    ExecProgram->>ExecProgram: Partition PATH into<br/>mise_added (tool bins, etc.)<br/>vs original (pristine PATH)
    ExecProgram->>ExecProgram: lookup_path = mise_added + (original - shims)

    ExecProgram->>Which: which_in("dummy", lookup_path)
    Note over Which: mise_added paths first →<br/>tool bin found before wrapper
    Which-->>ExecProgram: /home/user/.mise/installs/dummy/.../dummy

    ExecProgram->>RealBinary: execv(real_binary, args)
    RealBinary-->>Shell: "This is Dummy"
Loading

Fix All in Claude Code

Last reviewed commit: e71c8a4

@jdx jdx enabled auto-merge (squash) March 11, 2026 16:12
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

The pull request fixes an infinite recursion bug in mise x -- tool that occurred when wrapper scripts were present in PATH before mise shims. This was addressed by modifying the exec_program function to accept and prepend mise-managed tool binary paths to the PATH environment variable during execution, ensuring the real tool binary is found first. A new end-to-end test was added to validate this fix. A review comment suggested improving the efficiency and idiomatic nature of the PATH manipulation within exec_program by using iterators directly with std::env::join_paths instead of intermediate Vec collections.

Comment thread src/cli/exec.rs Outdated
Comment on lines +217 to +222
let filtered: Vec<_> = std::env::split_paths(&OsString::from(path_val))
.filter(|p| p != shims_dir)
.collect();
std::env::join_paths(&filtered).unwrap()
// Prepend tool paths so real binaries are found before wrapper scripts
let combined: Vec<_> = tool_paths.into_iter().chain(filtered).collect();
std::env::join_paths(&combined).unwrap()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This can be made more efficient and idiomatic by avoiding the intermediate Vec collections. std::env::join_paths can directly take an iterator, which removes the need to collect() into filtered and combined vectors first. Consider using std::iter::once to prepend tool_paths as an iterator.

Suggested change
let filtered: Vec<_> = std::env::split_paths(&OsString::from(path_val))
.filter(|p| p != shims_dir)
.collect();
std::env::join_paths(&filtered).unwrap()
// Prepend tool paths so real binaries are found before wrapper scripts
let combined: Vec<_> = tool_paths.into_iter().chain(filtered).collect();
std::env::join_paths(&combined).unwrap()
let filtered = std::env::split_paths(&OsString::from(path_val))
.filter(|p| p != shims_dir);
// Prepend tool paths so real binaries are found before wrapper scripts
let combined = tool_paths.into_iter().chain(filtered);
std::env::join_paths(combined).unwrap()

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Mar 11, 2026

Hyperfine Performance

mise x -- echo

Command Mean [ms] Min [ms] Max [ms] Relative
mise-2026.3.7 x -- echo 25.4 ± 0.6 24.0 30.7 1.00
mise x -- echo 25.8 ± 3.3 24.0 80.8 1.01 ± 0.13

mise env

Command Mean [ms] Min [ms] Max [ms] Relative
mise-2026.3.7 env 25.1 ± 0.6 23.4 28.2 1.00 ± 0.04
mise env 25.0 ± 0.6 23.4 27.3 1.00

mise hook-env

Command Mean [ms] Min [ms] Max [ms] Relative
mise-2026.3.7 hook-env 25.9 ± 0.7 24.4 31.0 1.00
mise hook-env 26.0 ± 0.5 24.7 29.3 1.01 ± 0.03

mise ls

Command Mean [ms] Min [ms] Max [ms] Relative
mise-2026.3.7 ls 25.1 ± 0.5 23.9 27.4 1.00
mise ls 25.2 ± 0.6 23.7 27.3 1.00 ± 0.03

xtasks/test/perf

Command mise-2026.3.7 mise Variance
install (cached) 156ms 157ms +0%
ls (cached) 86ms 85ms +1%
bin-paths (cached) 88ms 88ms +0%
task-ls (cached) 862ms 836ms +3%

When both a wrapper directory (e.g. .devcontainer/bin) and the shims
directory are in PATH, with the wrapper appearing first, `mise x -- tool`
would resolve the tool back to the wrapper script instead of the real
binary. This caused infinite recursion, growing the environment on each
iteration until exceeding ARG_MAX (~2MB), resulting in E2BIG errors.

The root cause is PathEnv ordering: paths before shims go into `pre`,
tool bins go into `mise`, and `to_vec()` returns `pre + mise + post`.
So wrapper dirs in `pre` are searched before tool bins in `mise`.
The existing shim-stripping in `exec_program` only removed the shims
directory, leaving other wrapper directories untouched.

Fix: pass tool bin paths into `exec_program` and prepend them to the
lookup PATH (used only for binary resolution, not inherited by the
child process). This ensures the real tool binary is always found
before any wrapper scripts regardless of PATH ordering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@jdx jdx force-pushed the fix/exec-wrapper-recursion-with-shims branch from 6103141 to e71c8a4 Compare March 11, 2026 16:38
Comment thread src/cli/exec.rs
Comment on lines +265 to +288
let all_paths: Vec<_> = std::env::split_paths(&OsString::from(path_val)).collect();
let mise_added: Vec<_> = all_paths
.iter()
.filter(|p| {
let normalized = crate::file::replace_path(p)
.to_string_lossy()
.to_lowercase()
.replace('/', "\\");
!pristine.contains(&normalized)
})
.cloned()
.collect();
let original: Vec<_> = all_paths
.iter()
.filter(|p| {
let expanded = crate::file::replace_path(p);
expanded.to_string_lossy().to_lowercase().replace('/', "\\") != shims_normalized
let normalized = crate::file::replace_path(p)
.to_string_lossy()
.to_lowercase()
.replace('/', "\\");
pristine.contains(&normalized) && !is_shims(p)
})
.cloned()
.collect();
std::env::join_paths(&filtered).unwrap()
std::env::join_paths(mise_added.iter().chain(original.iter())).unwrap()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Windows: normalization logic duplicated three times

The replace_path(...).to_string_lossy().to_lowercase().replace('/', "\\") normalization is computed identically in three separate places within this closure: once to build pristine, once in mise_added's filter, and once in original's filter. Extracting it into a small closure (similar to how is_shims was already extracted) would reduce duplication and make a future fix (e.g. canonicalization) a single-site change:

let normalize = |p: &std::path::PathBuf| {
    crate::file::replace_path(p)
        .to_string_lossy()
        .to_lowercase()
        .replace('/', "\\")
};
let pristine: std::collections::HashSet<_> = crate::env::PATH
    .iter()
    .map(|p| normalize(p))
    .collect();
// ...then reuse normalize(p) in each filter

Fix in Claude Code

Comment thread src/cli/exec.rs
Comment on lines 207 to +222
let shims_dir = &*crate::dirs::SHIMS;
let filtered: Vec<_> = std::env::split_paths(&OsString::from(path_val))
.filter(|p| p != shims_dir)
let pristine: std::collections::HashSet<_> = crate::env::PATH.iter().collect();
let all_paths: Vec<_> = std::env::split_paths(&OsString::from(path_val)).collect();
// Mise-added paths first (preserving relative order)
let mise_added: Vec<_> = all_paths
.iter()
.filter(|p| !pristine.contains(p))
.cloned()
.collect();
std::env::join_paths(&filtered).unwrap()
// Then original system paths (minus shims)
let original: Vec<_> = all_paths
.iter()
.filter(|p| pristine.contains(p) && *p != shims_dir)
.cloned()
.collect();
std::env::join_paths(mise_added.iter().chain(original.iter())).unwrap()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shims dir not filtered from mise_added bucket

shims_dir is only excluded from the original bucket (paths that were in the pristine PATH). If the shims directory was placed into PATH by mise itself—rather than by the user's dotfiles—it would appear in mise_added and be promoted to the front of the lookup PATH, alongside the real tool bins.

In practice this is safe today because mise's PATH construction puts tool-bin directories before the shims directory, so the real binary is still resolved first. However, a defensive && *p != shims_dir guard on the mise_added filter would make the intent explicit and guard against any future reordering in mise's PATH construction:

Suggested change
let shims_dir = &*crate::dirs::SHIMS;
let filtered: Vec<_> = std::env::split_paths(&OsString::from(path_val))
.filter(|p| p != shims_dir)
let pristine: std::collections::HashSet<_> = crate::env::PATH.iter().collect();
let all_paths: Vec<_> = std::env::split_paths(&OsString::from(path_val)).collect();
// Mise-added paths first (preserving relative order)
let mise_added: Vec<_> = all_paths
.iter()
.filter(|p| !pristine.contains(p))
.cloned()
.collect();
std::env::join_paths(&filtered).unwrap()
// Then original system paths (minus shims)
let original: Vec<_> = all_paths
.iter()
.filter(|p| pristine.contains(p) && *p != shims_dir)
.cloned()
.collect();
std::env::join_paths(mise_added.iter().chain(original.iter())).unwrap()
let mise_added: Vec<_> = all_paths
.iter()
.filter(|p| !pristine.contains(p) && *p != shims_dir)
.cloned()
.collect();

Fix in Claude Code

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Comment thread src/cli/exec.rs
let mise_added: Vec<_> = all_paths
.iter()
.filter(|p| !pristine.contains(p))
.cloned()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shims not filtered from mise-added lookup paths

Low Severity

The old code unconditionally filtered the shims directory from the entire lookup PATH. The new code only filters shims from the original partition (pristine.contains(p) && *p != shims_dir), but the mise_added partition has no shims filtering at all. If the shims directory ever appears in all_paths but is not present in the pristine set (e.g., added by a cached env, a plugin, or a future code path), it would pass through mise_added unfiltered, re-enabling the recursive shim execution the fix is meant to prevent. This is a loss of defensive filtering compared to the previous implementation.

Additional Locations (1)
Fix in Cursor Fix in Web

@jdx jdx merged commit 7a0d646 into main Mar 11, 2026
36 checks passed
@jdx jdx deleted the fix/exec-wrapper-recursion-with-shims branch March 11, 2026 16:51
jdx pushed a commit that referenced this pull request Mar 30, 2026
…8802)

## Summary

Three code paths spawn subprocesses that can invoke mise-managed tools
(e.g. `gh`, `git`) without stripping mise shims from PATH. When the tool
resolves to a mise shim, it re-enters mise, which may trigger the same
subprocess again — causing **infinite recursion (fork bomb)**.

Observed: load average >1800 on an ARM SBC, system unresponsive. Also
reproduced on macOS.

## Root Cause

Three subprocess-spawning paths inherit shims in PATH:

### 1. `credential_command` in `src/github.rs`
`get_credential_command_token()` runs `sh -c <cmd>` (e.g. `gh auth
token`). If `gh` is a mise shim → recursion.

### 2. `git credential fill` in `src/github.rs`
`get_git_credential_token()` runs `git credential fill`. If `git` is a
mise shim, or git's credential helper invokes `gh` (via `gh auth
setup-git`) → recursion.

### 3. `exec()` template function in `src/tera.rs` (primary trigger)
When a `.mise.toml` contains:
```toml
[env]
GITHUB_TOKEN = "{{exec(command='gh auth token')}}"
```
Every `mise hook-env` in that directory runs `gh auth token` via
`tera_exec()` with `PRISTINE_ENV`, which includes shims in PATH → `gh`
shim → `mise exec` → evaluates env → runs `gh auth token` again →
infinite recursion.

This is the most common trigger because `exec()` in `[env]` is the
idiomatic way to derive env vars from CLI tools.

## Fix

Add a shared `file::path_env_without_shims()` helper (next to the
existing `which_no_shims()`) that filters `dirs::SHIMS` out of PATH and
returns an `OsString` suitable for `.env("PATH", ...)`. Used in all
three call sites:

- **`src/github.rs`**: `get_credential_command_token()` and
`get_git_credential_token()`
- **`src/tera.rs`**: `tera_exec()`

Follows the same shim-stripping pattern established in:
- PR #8475 (`dependency_env()`)
- PR #8276 / #8560 (`exec_program()`)
- PR #8189 (Windows variant)
- PR #8402 (`uv` venv creation via `which_no_shims()`)

Related: Discussion #6374 — same user-facing symptom ("Cannot fork")
from `exec()` path.

## Reproduction

### Tera exec path (most common)
1. Install `gh` via mise (`gh = "latest"`)
2. Create a project `.mise.toml` with `[env] GITHUB_TOKEN =
"{{exec(command='gh auth token')}}"`
3. `cd` into that directory — `mise hook-env` fires, evaluates the
template, runs `gh auth token` through the shim → fork bomb

### Credential command path
1. Install `gh` via mise, set `credential_command = "gh auth token"` in
mise settings
2. Trigger any GitHub API call → fork bomb

## Changes

- **`src/file.rs`**: New `path_env_without_shims()` public helper —
shared by all call sites
- **`src/github.rs`**: Use shared helper in
`get_credential_command_token()` and `get_git_credential_token()`
- **`src/tera.rs`**: Use shared helper in `tera_exec()`
- **`e2e/cli/test_github_credential_shim_recursion`**: e2e test for
credential_command path
- **`e2e/cli/test_tera_exec_shim_recursion`**: e2e test for tera exec()
path

## Note on env var workaround

Setting `MISE_GITHUB_TOKEN=""` does **not** prevent the hang —
`resolve_token()` filters empty strings with `.filter(|t|
!t.is_empty())`, so it falls through to subprocess-based fallbacks. A
non-empty sentinel value (e.g. `MISE_GITHUB_TOKEN="none"`) works for the
github.rs paths but the tera `exec()` path is independent of token
resolution entirely.

## Test plan

- [x] `cargo check` passes
- [ ] CI: `cargo test` passes  
- [ ] CI: e2e test `test_github_credential_shim_recursion` passes
- [ ] CI: e2e test `test_tera_exec_shim_recursion` passes
- [x] Manual test: project with `exec(command='gh auth token')` in
`.mise.toml`, `cd` does not fork-bomb

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant