fix(exec): resolve wrapper recursion when shims are in PATH#8560
Conversation
Summary of ChangesHello, 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 Highlights
Changelog
Activity
Using Gemini Code AssistThe 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
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 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
|
Greptile SummaryThis PR fixes an infinite-recursion bug in Key changes:
Confidence Score: 4/5
Important Files Changed
Sequence DiagramsequenceDiagram
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"
Last reviewed commit: e71c8a4 |
There was a problem hiding this comment.
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.
| 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() |
There was a problem hiding this comment.
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.
| 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() |
Hyperfine Performance
|
| 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>
6103141 to
e71c8a4
Compare
| 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() |
There was a problem hiding this comment.
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| 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() |
There was a problem hiding this comment.
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:
| 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(); |
There was a problem hiding this comment.
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.
| let mise_added: Vec<_> = all_paths | ||
| .iter() | ||
| .filter(|p| !pristine.contains(p)) | ||
| .cloned() |
There was a problem hiding this comment.
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)
…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>


Summary
.devcontainer/bin) and the shims directory are in PATH with the wrapper appearing first,mise x -- toolresolved the tool back to the wrapper script instead of the real binary, causing infinite recursion until E2BIGPathEnvordering puts paths before shims (pre) before tool bins (mise), so wrappers are found first. The existing shim-stripping inexec_programonly removed the shims directoryexec_programand 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 scriptse2e/cli/test_exec_wrapper_recursion_with_shimswhich reproduces the exact scenario (times out without the fix, passes with it)Context
This was discovered via a Figma devcontainer where
.devcontainer/bin/gitleaks(amise execwrapper) sat before~/.mise/shimsin PATH. Thecheck-secretspre-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
test_exec_wrapper_recursion_with_shimspasses (was timing out before fix)test_exec_wrapper_recursionstill passestest_exec_shim_recursionstill passescargo checkpasseshk 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
PATHtaking precedence duringmise xlookup. The change is limited to lookup-timePATHmanipulation and is covered by a new recursion regression test.Overview
Prevents
mise exec/mise xfrom infinitely recursing when a wrapper script (e.g..devcontainer/bin/tool) and the mise shims directory are both onPATHand the wrapper appears first.Instead of only stripping shims during binary resolution,
exec_programnow builds a lookup-onlyPATHthat prioritizes mise-added paths (tool bins and_.path) ahead of the original systemPATHwhile still excluding shims; the child process continues to inherit the originalPATH. Adds an e2e regression teste2e/cli/test_exec_wrapper_recursion_with_shimsthat reproduces the devcontainer wrapper+shims ordering scenario.Written by Cursor Bugbot for commit e71c8a4. This will update automatically on new commits. Configure here.