Skip to content

feat(exec): add process sandboxing for mise x and mise run#8845

Merged
jdx merged 25 commits intomainfrom
feat/sandbox
Apr 2, 2026
Merged

feat(exec): add process sandboxing for mise x and mise run#8845
jdx merged 25 commits intomainfrom
feat/sandbox

Conversation

@jdx
Copy link
Copy Markdown
Owner

@jdx jdx commented Apr 2, 2026

Summary

  • Add lightweight process sandboxing for mise exec and mise run, inspired by zerobox
  • Linux: Landlock for filesystem, seccomp-bpf for network
  • macOS: sandbox-exec (Seatbelt) with generated profiles, including per-host network filtering
  • Task-level sandbox config via deny_*/allow_* fields in mise.toml
  • Experimental feature (requires experimental=true)
  • Documentation at docs/sandboxing.md
  • macOS e2e test job added to CI

Usage

# Full lockdown
mise x --deny-all -- node script.js

# Block network
mise x --deny-net -- npm run build

# Block writes except to ./dist
mise x --allow-write=./dist -- npm run build

# Task-level sandboxing
[tasks.build]
run = "npm run build"
deny_net = true
allow_write = ["./dist"]

Platform support

Feature Linux macOS
Deny/allow reads Landlock Seatbelt
Deny/allow writes Landlock Seatbelt
Deny all network seccomp Seatbelt
Per-host network Not yet Seatbelt
Env filtering Built-in Built-in
Docker support Yes N/A

Test plan

  • Unit tests pass (cargo test --all-features — 540 passed)
  • E2E tests for deny-write, deny-read, deny-net, deny-env, deny-all, task sandbox
  • All e2e sandbox tests pass on Linux
  • macOS e2e tests (new CI job)
  • Pre-commit hooks pass

🤖 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) and mise run, with new --deny-*/--allow-* flags and equivalent mise.toml task 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-exec profile with optional per-host network allowlist) and wires it through both direct exec and task execution, including env filtering/clearing when deny_env is 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.

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

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.

Comment on lines +72 to +76
if let Ok(real) = dir.canonicalize()
&& seen.insert(real.clone())
{
ruleset = add_path_rule(ruleset, &real, read_access)?;
}
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.

critical

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.

Suggested change
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)?;
}
}

Comment on lines +79 to +84
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)?;
}
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.

critical

This block also uses the unstable let_chains feature, which will prevent compilation on stable Rust.

                    if let Some(parent) = dir.parent() {
                        if parent != std::path::Path::new("/") && seen.insert(parent.to_path_buf()) {
                            ruleset = add_path_rule(ruleset, parent, read_access)?;
                        }
                    }

Comment on lines +94 to +98
&& let Ok(val) = std::env::var(key)
{
filtered.insert(key.clone(), val);
}
}
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.

critical

This block uses the unstable let_chains feature, which will prevent compilation on stable Rust.

            if !filtered.contains_key(key) {
                if let Ok(val) = std::env::var(key) {
                    filtered.insert(key.clone(), val);
                }
            }

Comment on lines +102 to +106
if !filtered.contains_key(&k)
&& let Ok(val) = std::env::var(key)
{
filtered.insert(k, val);
}
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.

critical

This block uses the unstable let_chains feature, which will prevent compilation on stable Rust.

Suggested change
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);
}
}

Comment on lines +612 to +625
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(())
});
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.

high

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()).

Comment on lines +251 to +255
for (k, _) in std::env::vars() {
if !env.contains_key(&k) {
env::remove_var(&k);
}
}
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

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.

// 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}:*\"))"));
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

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.

Comment on lines +44 to +55
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);
}
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.

security-medium medium

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.

jdx and others added 3 commits April 2, 2026 03:15
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>
jdx and others added 3 commits April 2, 2026 03:21
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-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 2, 2026

Greptile Summary

Adds an experimental process sandboxing feature for mise exec/mise run using Landlock+seccomp on Linux and Seatbelt (sandbox-exec) on macOS, with task-level deny_*/allow_* fields in mise.toml. The PR addresses several issues from the previous review round (DNS resolution for macOS ip predicates, loud failure for --allow-net on Linux, proper env_clear scoping, write-path read-allow ordering in SBPL).

  • sandbox-exec is deprecated in macOS 14+ and users on recent macOS versions may see deprecation warnings.
  • Both platforms implicitly block the project CWD under deny_read, which is the most likely source of user confusion when the feature graduates from experimental.

Confidence Score: 4/5

Safe 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

Filename Overview
src/sandbox/mod.rs Core SandboxConfig type with is_active/effective_deny_* helpers, filter_env, and dual apply paths — logic is sound but creates two parallel code paths
src/sandbox/landlock.rs Landlock ruleset: correctly uses read_access only when deny_read is active; deny_write-only grants read to '/' so reads remain unrestricted; non-existent allow_write paths emit a warning
src/sandbox/macos.rs Seatbelt profile: DNS resolution now produces IP literals for allow_net; allow_write read-allows emitted after (deny file-read*) for correct last-match ordering; CWD not auto-whitelisted under deny_read
src/sandbox/seccomp.rs Blocks AF_INET/AF_INET6 socket()/socketpair() via seccomp-bpf; returns EPERM; supports x86_64 and aarch64 only
src/cmd.rs apply_sandbox(): Linux uses pre_exec hook, macOS rewrites command to sandbox-exec; execute_raw() correctly overrides piped stdio back to inherit
src/task/task_executor.rs build_sandbox_for_task correctly merges task-level and CLI sandbox configs; env filtered before setting on cmd; apply_sandbox() awaited before block_in_place
src/cli/exec.rs Adds deny_/allow_ CLI flags; filters env in current process before execve; calls sandbox.apply() for Landlock in-process on Linux or sandbox-exec on macOS
src/task/mod.rs Adds deny_all/deny_/allow_ fields to Task struct with serde defaults; deny_all expanded into individual deny flags when building SandboxConfig
e2e/sandbox/test_sandbox_deny_write Tests write denial outside /tmp and allow-write; pre-creates SANDBOX_DIR so Landlock rule path exists at rule-creation time
e2e/sandbox/test_sandbox_deny_all Tests deny-all write blocking; avoids deny-all for env check because macOS deny_read can abort bash (noted in comment)

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]
Loading

Fix All in Claude Code

Reviews (10): Last reviewed commit: "[autofix.ci] apply automated fixes (atte..." | Re-trigger Greptile

autofix-ci bot and others added 6 commits April 2, 2026 03:32
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>
jdx and others added 2 commits April 2, 2026 04:24
- 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>
@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 2, 2026

Hyperfine Performance

mise x -- echo

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 ⚠️ 2574ms -75%

⚠️ Warning: task-ls cached performance variance is -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>
@greg0x
Copy link
Copy Markdown

greg0x commented Apr 2, 2026

lol, I literally just started designing the same thing and came here to look around 😄
Nice one! Let me know if I can help get this merged.

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!

jdx and others added 2 commits April 2, 2026 12:34
…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>
jdx and others added 3 commits April 2, 2026 13:21
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>
@jdx
Copy link
Copy Markdown
Owner Author

jdx commented Apr 2, 2026

Bugbot run

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.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

@jdx jdx merged commit ea2069e into main Apr 2, 2026
37 checks passed
@jdx jdx deleted the feat/sandbox branch April 2, 2026 14:02
mise-en-dev added a commit that referenced this pull request Apr 3, 2026
### 🚀 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)
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.

2 participants