feat(exec): add process sandboxing for mise x and mise run#8845
Conversation
There was a problem hiding this comment.
Code Review
This pull request implements an experimental process sandboxing feature for mise exec and mise run, providing granular control over filesystem, network, and environment variable access on Linux and macOS. The review identifies several technical issues, most notably the use of the unstable let_chains feature which breaks stable compilation, and a lack of async-signal safety in the Linux pre_exec implementation that could lead to deadlocks. Additional feedback addresses thread-safety concerns with environment manipulation, incorrect hostname handling in macOS Seatbelt profiles, and the need for path canonicalization to prevent sandbox bypasses.
src/sandbox/landlock.rs
Outdated
| if let Ok(real) = dir.canonicalize() | ||
| && seen.insert(real.clone()) | ||
| { | ||
| ruleset = add_path_rule(ruleset, &real, read_access)?; | ||
| } |
There was a problem hiding this comment.
The use of let_chains (combining let with &&) is currently an unstable Rust feature. Unless this project explicitly uses a nightly toolchain and has enabled the #![feature(let_chains)] attribute, this will cause a compilation error on stable Rust. It is safer to use nested if let statements.
| if let Ok(real) = dir.canonicalize() | |
| && seen.insert(real.clone()) | |
| { | |
| ruleset = add_path_rule(ruleset, &real, read_access)?; | |
| } | |
| if let Ok(real) = dir.canonicalize() { | |
| if seen.insert(real.clone()) { | |
| ruleset = add_path_rule(ruleset, &real, read_access)?; | |
| } | |
| } |
src/sandbox/landlock.rs
Outdated
| if let Some(parent) = dir.parent() | ||
| && parent != std::path::Path::new("/") | ||
| && seen.insert(parent.to_path_buf()) | ||
| { | ||
| ruleset = add_path_rule(ruleset, parent, read_access)?; | ||
| } |
There was a problem hiding this comment.
| && let Ok(val) = std::env::var(key) | ||
| { | ||
| filtered.insert(key.clone(), val); | ||
| } | ||
| } |
| if !filtered.contains_key(&k) | ||
| && let Ok(val) = std::env::var(key) | ||
| { | ||
| filtered.insert(k, val); | ||
| } |
There was a problem hiding this comment.
This block uses the unstable let_chains feature, which will prevent compilation on stable Rust.
| if !filtered.contains_key(&k) | |
| && let Ok(val) = std::env::var(key) | |
| { | |
| filtered.insert(k, val); | |
| } | |
| if !filtered.contains_key(&k) { | |
| if let Ok(val) = std::env::var(key) { | |
| filtered.insert(k, val); | |
| } | |
| } |
| self.cmd.pre_exec(move || { | ||
| if (sandbox.effective_deny_read() || sandbox.effective_deny_write()) | ||
| && let Err(e) = crate::sandbox::landlock_apply(&sandbox) { | ||
| eprintln!("mise: landlock unavailable, filesystem sandbox not applied: {e}"); | ||
| } | ||
| if sandbox.effective_deny_net() { | ||
| if !sandbox.allow_net.is_empty() { | ||
| eprintln!("mise: per-host network filtering (--allow-net=<host>) is not supported on Linux, allowing all network"); | ||
| } else if let Err(e) = crate::sandbox::seccomp_apply() { | ||
| eprintln!("mise: seccomp unavailable, network sandbox not applied: {e}"); | ||
| } | ||
| } | ||
| Ok(()) | ||
| }); |
There was a problem hiding this comment.
The closure passed to pre_exec must be async-signal-safe. In a multi-threaded process, calling functions that perform heap allocations, use locks, or perform complex I/O (like eprintln!, eyre!, HashSet operations, or canonicalize inside landlock_apply) after fork() but before exec() can lead to deadlocks or undefined behavior.
Consider refactoring the sandbox application so that all preparation (like building the Landlock ruleset and opening file descriptors) happens in the parent process, and the pre_exec closure only performs the minimal necessary syscalls (e.g., ruleset.restrict_self()).
| for (k, _) in std::env::vars() { | ||
| if !env.contains_key(&k) { | ||
| env::remove_var(&k); | ||
| } | ||
| } |
There was a problem hiding this comment.
Iterating over and removing environment variables using std::env::vars() and env::remove_var() is not thread-safe in Rust. Since mise is a multi-threaded application (using tokio), this can lead to data races if other threads are active. It is generally safer to pass the filtered environment directly to the command being executed rather than modifying the process-global environment.
src/sandbox/macos.rs
Outdated
| // Always allow local/unix sockets | ||
| rules.push("(allow network* (local unix))".to_string()); | ||
| for host in &config.allow_net { | ||
| rules.push(format!("(allow network* (remote ip \"{host}:*\"))")); |
There was a problem hiding this comment.
Apple's Seatbelt (sandbox-exec) remote ip rule expects an IP address or CIDR range, not a hostname. Providing a hostname here will likely result in an invalid profile or fail to match the actual network traffic (which uses IPs). To support per-host filtering, you would need to resolve the hostnames to their corresponding IP addresses in the parent process before generating the profile.
| pub fn resolve_paths(&mut self) { | ||
| let cwd = std::env::current_dir().unwrap_or_default(); | ||
| let resolve = |paths: &mut Vec<PathBuf>| { | ||
| for p in paths.iter_mut() { | ||
| if p.is_relative() { | ||
| *p = cwd.join(&p); | ||
| } | ||
| } | ||
| }; | ||
| resolve(&mut self.allow_read); | ||
| resolve(&mut self.allow_write); | ||
| } |
There was a problem hiding this comment.
The resolve_paths function joins relative paths with the current working directory but does not canonicalize them. Sandboxing mechanisms like Landlock and Seatbelt are more robust when provided with absolute, canonicalized paths to prevent potential bypasses via symlinks or .. components. Additionally, if current_dir() fails, relative paths remain relative, which might cause the sandbox application to fail or behave incorrectly.
Add lightweight process sandboxing inspired by zerobox (https://github.com/afshinm/zerobox). Supports restricting filesystem reads/writes, network access, and environment variables with granular allow/deny controls. - Linux: Landlock for filesystem, seccomp-bpf for network - macOS: sandbox-exec (Seatbelt) with generated profiles - Both: env var filtering built into mise - Task sandboxing via deny_*/allow_* fields in mise.toml - Per-host network filtering on macOS (--allow-net=<host>) - Experimental feature (requires experimental=true) - macOS e2e test job added to CI - Documentation added at docs/sandboxing.md Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Only allow reading from system prefixes (/usr, /lib, /opt, /nix, etc.) and mise install dirs when --deny-read is active. Previously allowed all PATH directories which could include user home dirs, making --deny-read too permissive. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Drop /opt from Linux (not needed), narrow /opt to /opt/homebrew on macOS, narrow /var to /var/run on macOS. Keeps /etc since tools need resolv.conf, ssl certs, etc. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Grant /dev full access (not just read) when deny_read is active, so /dev/null and /dev/tty work under --deny-all - Use cmd.get_program() instead of display_path() for macOS sandbox-exec to avoid ~ expansion breaking executable paths - Apply env_clear() on the replacement sandbox-exec Command on macOS when deny_env is active Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Greptile SummaryAdds an experimental process sandboxing feature for
Confidence Score: 4/5Safe to merge — gated behind experimental=true; critical correctness issues from prior review rounds are addressed The critical bugs from the previous threads (hostname-in-ip-predicate, Linux allow-net silent bypass, wrong env_clear scope, SBPL write-allow ordering) all appear fixed. Remaining concerns are P2: sandbox-exec deprecation on macOS 14+, project CWD not auto-whitelisted under deny_read (surprising UX but not a bypass), and heap allocation in pre_exec (theoretically unsafe but practically benign). None block merge. src/sandbox/macos.rs and src/sandbox/landlock.rs — both implicitly block the project CWD under deny_read, the most likely user confusion point Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[mise x / mise run] --> B{sandbox.is_active?}
B -- No --> C[exec program normally]
B -- Yes --> D[ensure_experimental]
D --> E[filter_env]
E --> F{Platform?}
F -- Linux --> G[CmdLineRunner with_sandbox]
G --> H[pre_exec hook in child]
H --> I{deny_read/write?}
I -- Yes --> J[apply_landlock]
H --> K{deny_net?}
K -- Yes --> L[apply_seccomp block AF_INET]
J --> M[execve target]
L --> M
F -- macOS --> N[apply_sandbox]
N --> O[generate SBPL profile]
O --> P{allow_net entries?}
P -- Yes --> Q[tokio DNS lookup to IPs]
Q --> R[emit ip literal rules]
P -- No --> S[emit deny network* only]
R --> T[rewrite cmd to sandbox-exec]
S --> T
T --> U[exec sandbox-exec]
F -- Windows/Other --> V[warn unsandboxed run normally]
Reviews (10): Last reviewed commit: "[autofix.ci] apply automated fixes (atte..." | Re-trigger Greptile |
The dirs crate is not a dependency of mise. Use crate::env::MISE_DATA_DIR instead of dirs::home_dir() for the Seatbelt profile. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Seatbelt's `ip` predicate requires IP address literals, not hostnames. Resolve --allow-net hostnames via ToSocketAddrs at profile generation time. Also deduplicate the mDNSResponder allow rule (was emitted once per host). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Resolve --allow-net hostnames to IPs in parallel using tokio::net::lookup_host instead of blocking std::net::ToSocketAddrs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
exec_program is always called from async contexts, so make it async directly rather than using block_in_place to bridge. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Make CmdLineRunner::apply_sandbox() an async public method called before execute() instead of being called synchronously inside spawn_with_etxtbsy_retry(). This removes the block_in_place hack for macOS DNS resolution. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Move self_update back to cfg(unix) deps (was accidentally moved to cfg(target_os = "linux") which broke macOS builds) - Fix execute_raw() to use inherited stdio instead of piped to prevent deadlocks when child produces >64KB output - Merge macos-e2e job into unit job to avoid duplicate macOS builds Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… bypassing Per-host network filtering is not supported on Linux (seccomp can only do all-or-nothing). Previously this silently allowed all network with a warning. Now it fails with a clear error message. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Only handle_access for the operations being restricted. Previously --deny-read also blocked writes because Landlock denies unruled operations by default. - Escape double quotes and backslashes in user-provided paths and hostnames before interpolating into Seatbelt SBPL profiles to prevent injection of arbitrary sandbox rules. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
On macOS, sandbox-exec sends SIGABRT on violation which propagates through subshell capture. Use --deny-env instead of --deny-all for the env filtering assertion to avoid abort in $() subshell. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Hyperfine Performance
|
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|---|---|---|---|---|
mise-2026.4.1 x -- echo |
19.6 ± 0.8 | 18.4 | 25.8 | 1.00 |
mise x -- echo |
20.7 ± 2.9 | 19.3 | 82.5 | 1.05 ± 0.15 |
mise env
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|---|---|---|---|---|
mise-2026.4.1 env |
19.3 ± 0.7 | 17.9 | 21.4 | 1.00 |
mise env |
19.9 ± 0.7 | 18.7 | 22.3 | 1.03 ± 0.05 |
mise hook-env
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|---|---|---|---|---|
mise-2026.4.1 hook-env |
20.0 ± 1.1 | 18.6 | 30.5 | 1.00 |
mise hook-env |
20.7 ± 0.9 | 19.4 | 29.0 | 1.03 ± 0.07 |
mise ls
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|---|---|---|---|---|
mise-2026.4.1 ls |
19.3 ± 0.5 | 18.2 | 21.2 | 1.00 |
mise ls |
20.0 ± 0.8 | 18.7 | 23.9 | 1.04 ± 0.05 |
xtasks/test/perf
| Command | mise-2026.4.1 | mise | Variance |
|---|---|---|---|
| install (cached) | 126ms | 125ms | +0% |
| ls (cached) | 67ms | 69ms | -2% |
| bin-paths (cached) | 71ms | 71ms | +0% |
| task-ls (cached) | 629ms | -75% |
…dant rules - Walk up to nearest existing ancestor for Landlock allow_write/allow_read paths that don't exist yet (e.g., --allow-write=./dist before build) - Move env_clear() into Linux-specific block so macOS can still read the filtered env vars before copying to sandbox-exec Command - Remove /tmp and /dev from SYSTEM_READ_PATHS (they get full_access separately) - Canonicalize allow paths to resolve symlinks (e.g., /var -> /private/var on macOS) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
lol, I literally just started designing the same thing and came here to look around 😄 Great project btw! Fits exactly how I think about this — and even better in many areas. E.g. the sops secret handling is genuinely awesome! |
…nings - Add /tmp and /dev read access in Landlock deny_read-only branch (they were removed from SYSTEM_READ_PATHS but not added back) - Emit allow-read rules for allow_write paths AFTER deny-read in SBPL profile so they aren't overridden by the blanket deny - Warn instead of silently skipping when allow paths don't exist on Linux (reverts ancestor walk which was too permissive) - Simplify deny-read e2e test to use HOME dir Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SandboxedCommand and apply() are only used on specific platforms but clippy --all-targets compiles them for test targets too. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Narrow /private to /private/tmp, /private/etc, /private/var/run instead of all of /private (which includes /private/var/tmp and user temp dirs) - Simplify deny-read test to only test the deny case (not allow-read which has edge cases with macOS temp dir paths) - Use /var/tmp for test path (outside system paths on both platforms) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When Landlock or seccomp fails (e.g., older kernels), bail with an error instead of silently running unsandboxed. A user who explicitly requests --deny-write or --deny-net should get a clear failure, not a warning that's easy to miss. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Bugbot run |
### 🚀 Features - **(exec)** add process sandboxing for mise x and mise run by @jdx in [#8845](#8845) ### 📚 Documentation - fix values for RUNTIME.osType and RUNTIME.archType. Simplify examples by @esteve in [#8785](#8785) ### 📦️ Dependency Updates - update ghcr.io/jdx/mise:copr docker digest to 6dd31ee by @renovate[bot] in [#8860](#8860) - update ghcr.io/jdx/mise:alpine docker digest to 4b8b285 by @renovate[bot] in [#8859](#8859) - update ghcr.io/jdx/mise:deb docker digest to 56ddc49 by @renovate[bot] in [#8861](#8861) - update ghcr.io/jdx/mise:rpm docker digest to b37cc3b by @renovate[bot] in [#8862](#8862) ### New Contributors - @esteve made their first contribution in [#8785](#8785)
Summary
mise execandmise run, inspired by zeroboxsandbox-exec(Seatbelt) with generated profiles, including per-host network filteringdeny_*/allow_*fields inmise.tomlexperimental=true)docs/sandboxing.mdUsage
Platform support
Test plan
cargo test --all-features— 540 passed)🤖 Generated with Claude Code
Note
High Risk
High risk because it changes how
mise exec/task execution spawns processes and mutates inherited environment, and introduces OS-level restrictions (Landlock/seccomp/Seatbelt) that could break existing workflows or behave differently across platforms.Overview
Adds an experimental process sandboxing layer for
mise exec(mise x) andmise run, with new--deny-*/--allow-*flags and equivalentmise.tomltask fields to restrict filesystem reads/writes, network access, and inherited environment variables.Implements platform-specific enforcement (Linux: Landlock for FS + seccomp-bpf for networking; macOS: generated
sandbox-execprofile with optional per-host network allowlist) and wires it through both direct exec and task execution, including env filtering/clearing whendeny_envis active and clear warnings/fallbacks for unsupported platforms.Updates generated CLI docs/manpages/completions, adds new sandboxing documentation, introduces e2e sandbox tests, and extends CI (macOS) to run the new e2e suite; adds Linux-only deps (
landlock,seccompiler) and lockfile updates.Written by Cursor Bugbot for commit e80ac94. This will update automatically on new commits. Configure here.