Skip to content

tyrchen/sbe

Repository files navigation

sbe β€” Sandbox Exec

Run any command in a kernel-enforced sandbox with sensible defaults per language ecosystem. Defend your development machine and CI runners against supply chain attacks. Supports macOS (Seatbelt / SBPL) and Linux (Landlock LSM + seccomp-bpf).

sbe run -- npm install
sbe run -- cargo build
sbe run -- pip install -r requirements.txt
sbe run -- mix deps.get
sbe run -- ./gradlew build

Why

Package managers execute arbitrary code during install and build: npm postinstall scripts, Rust build.rs, Python setup.py, Elixir mix compile hooks, Gradle plugins. A single compromised dependency can read your SSH keys, exfiltrate cloud credentials, install persistent malware, or establish C2 channels β€” all silently, in the background.

sbe wraps your existing tools in a self-applied kernel sandbox: macOS sandbox-exec or Linux Landlock + seccomp. No code changes, no new package manager. Just prefix your command with sbe run --.

What It Blocks

Attack Vector macOS (Seatbelt / SBPL) Linux (Landlock + seccomp)
Read ~/.ssh, ~/.aws, cloud creds SBPL file-read* denylist denyRead forbidden-list (see Caveats)
Write to /Library/Caches, LaunchAgents SBPL file-write* allowlist Landlock write allowlist
Network C2 on non-standard ports SBPL pins egress to proxy / :443 Landlock NET_CONNECT_TCP (β‰₯6.7) or :443
Second-stage download via curl/wget Proxy 403s non-allowlisted domains Same proxy, identical behaviour
osascript / AppleScript abuse SBPL process-exec denylist n/a (Linux)
sudo, pkexec, privilege escalation n/a (macOS) Lint refuses allowExec subpaths covering sudo/pkexec/etc.
Clipboard / screen exfiltration SBPL denies pbcopy/screencapture Allowlist omits them
Module load / kernel attack surface n/a seccomp blocks bpf, init_module, kexec_*, ptrace, …

Install

cargo install --path apps/cli

Or:

make install

Supported targets:

  • macOS β€” any release with /usr/bin/sandbox-exec (all SIP-compliant builds).
  • Linux β€” kernel β‰₯5.13 for basic enforcement, β‰₯6.7 for full per-port TCP filtering (Landlock ABI v4). On 5.13–6.6 a --allow-degraded fallback is available; without that flag, sbe refuses to start rather than silently downgrading.

CI (GitHub Actions)

The bundled composite action installs a prebuilt sbe binary for the runner's OS and architecture, then adds it to PATH. It works on both Linux and macOS GitHub-hosted runners:

jobs:
  build-linux:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: tyrchen/sbe@sbexec-v0.3.0   # or @master with `version: latest`
        with:
          version: latest
      - run: sbe --version
      - run: sbe run -- cargo build

  build-macos:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4
      - uses: tyrchen/sbe@sbexec-v0.3.0
        with:
          version: latest
      - run: sbe run -- cargo build

Inputs:

Input Default Description
version latest Release to install. Accepts latest, a semver (0.3.0), or a full tag (sbexec-v0.3.0).
github-token ${{ github.token }} Token used for releases API + asset download.

Outputs: version (resolved tag) and bin-path (absolute path to the installed binary).

Supported runner / architecture matrix (auto-detected via $RUNNER_OS and $RUNNER_ARCH):

Runner Arch Release artifact
ubuntu-* x86_64 x86_64-unknown-linux-musl
ubuntu-* arm64 aarch64-unknown-linux-musl
macos-* arm64 aarch64-apple-darwin

Linux runners on ubuntu-latest / ubuntu-24.04 (kernel 6.x) get full enforcement via Landlock ABI v4 + seccomp-bpf. macOS runners use sandbox-exec / SBPL.

Quick Start

# Auto-detects ecosystem from command name or project files
sbe run -- npm install
sbe run -- cargo build

# Specify ecosystem explicitly
sbe run -p python -- pip install flask

# See what policy would be installed (does not execute)
sbe run --dry-run -- npm install

# Print resolved config + generated policy
sbe inspect -- cargo build

# List all default profiles
sbe profiles

# Disable network sandboxing for debugging
sbe run --allow-all-network -- npm install

# Add a custom allowed domain
sbe run -n "api.mycompany.com" -- npm install

# Allow build-time downloads (enables curl/wget + adds domains to proxy)
sbe run -f "download.example.com" -- cargo build

# Allow an extra binary
sbe run -e /usr/bin/curl -- npm install

# Stream sandbox violations in real-time
sbe run --audit -- npm install

# Linux: proceed under a kernel without ABI v4 net filter (best-effort)
sbe run --allow-degraded -- cargo build

Architecture

                          sbe CLI
                            β”‚
              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚                           β”‚
       Profile/Config             SandboxBackend          (cfg-selected at
       Resolver                   trait                    compile time)
       (sbe-core)                       β”‚
                            β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                            β”‚                       β”‚
                  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”
                  β”‚  MacosSandbox    β”‚   β”‚  LinuxSandbox     β”‚
                  β”‚  (sandbox-exec)  β”‚   β”‚  (Landlock +      β”‚
                  β”‚                  β”‚   β”‚   seccomp +       β”‚
                  β”‚  - SBPL gen      β”‚   β”‚   pre_exec)       β”‚
                  β”‚  - tempfile      β”‚   β”‚  - Ruleset build  β”‚
                  β”‚  - spawn -f      β”‚   β”‚  - BPF compile    β”‚
                  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                            β”‚                       β”‚
                            β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                        β”‚
                                        β–Ό
                                  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                                  β”‚  user    β”‚     HTTP_PROXY β†’ sbe-proxy
                                  β”‚  command β”‚ ──────────────────────────►
                                  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     (same path both platforms)

Two-layer network defense:

  1. Kernel layer: SBPL or Landlock pins outbound traffic to localhost:PROXY_PORT (or :443).
  2. Application layer (proxy): an HTTP CONNECT proxy checks the requested domain against the per-ecosystem allowlist before tunneling.

This combination defeats CDN-backed registries: SBPL and Landlock can't filter by hostname, but the proxy does β€” and the kernel forces every TCP egress through it.

What sbe Does Not Protect Against

The README's "What It Blocks" table summarises the wins. These are the known gaps β€” places where the marketing implies coverage that the implementation can't actually deliver. Don't trust sbe to be the last line of defense against any of these.

  • /proc cross-process snooping (Linux). The baseline read anchors include /proc/, so a sandboxed build script can list every process the invoking user owns and read its /proc/<pid>/environ, cmdline, cwd, fd/*. If your shell exported AWS_SECRET_ACCESS_KEY, an attacker-controlled npm install sees it. Mitigation: set kernel.yama.ptrace_scope=2 and avoid putting secrets in env vars of unrelated processes; macOS isn't affected.
  • DNS-over-UDP is unfiltered (Linux). Landlock has no UDP filter at any ABI. With /etc/resolv.conf readable, an attacker can encode exfil data in DNS subdomains and the kernel resolver will deliver them. The HTTP CONNECT proxy filters HTTP/HTTPS by hostname but never sees DNS itself.
  • TLS to port 443 on any host when proxy is disabled. JVM tools (Maven, sbt's coursier, Gradle's resolver) don't honor HTTP_PROXY env, so the java profile ships with enableProxy: false and Landlock allows TCP egress on port 443 to anywhere. A compromised Maven plugin can establish a TLS C2 channel to any host. Kernel still blocks every non-443 outbound; filesystem and exec restrictions still apply.
  • Gradle on Linux disables the kernel net filter entirely. Gradle's CLI ↔ daemon IPC uses a random localhost TCP port that Landlock v4 can't express. Users opting into Gradle via allowAllNetwork: true lose all kernel TCP filtering for that profile.
  • /dev/tcp, /dev/udp (bash built-ins). bash is in the default allowExec, and bash's < /dev/tcp/host/port opens a socket in-process. Same kernel syscalls (socket+connect); the proxy doesn't see this traffic. On port-443, fully unfiltered (see above).
  • Audit logging is best-effort on Linux. The auditor reads /dev/kmsg, which requires CAP_SYSLOG on most hardened hosts (kernel.dmesg_restrict=1 is the Ubuntu default). When it can't read kmsg, sbe falls back to "violations surface as EACCES exit codes" β€” silent for any attack that doesn't trip a kernel deny event (DNS exfil, /proc snooping, TLS:443 C2).

Linux Backend Caveats

These differences from the macOS path are surface-level β€” the same sbe run UX still works. Documented here so you know what you're getting:

  • denyRead is allowlist-omission, not subtractive deny. Landlock has no way to subtract from a granted subtree. sbe ships a curated read-allowlist on Linux (/etc, /lib, /usr, /proc, /sys, /tmp, $HOME XDG dirs) that intentionally excludes ~/.ssh, ~/.aws, etc. Anything you list in denyRead becomes a sealed forbidden-list: future config changes that try to grant read on a forbidden path are rejected at backend-time.
  • denyExec is a no-op. Landlock is allowlist-only; denyExec entries in a Linux profile emit a warning and are otherwise ignored. The defaults ship a per-binary allowExec enumeration that omits sudo, su, pkexec, doas, chsh, chfn, newgrp, sg, passwd, gpasswd, mount, umount. A .sbe.yaml that grants allowExec: ["/usr/bin/"] is rejected at startup (use --allow-degraded to override after considering the threat model).
  • PR_SET_NO_NEW_PRIVS is mandatory. Linux requires it for unprivileged seccomp; sbe sets it before applying any filter. The flag persists across execve and disables setuid bits across the descendant tree. Consequence: sudo / su / pkexec cannot escalate β€” this is desired. The handful of tools that depend on setuid binaries (e.g., legacy ping) will fail; the vast majority of build scripts are unaffected.
  • UDP is unfiltered. Landlock filters only TCP. DNS over UDP, NTP, QUIC egress are not subject to per-port enforcement. The seccomp baseline blocks AF_PACKET raw sockets but not SOCK_DGRAM on AF_INET. The HTTP CONNECT proxy is TCP-only by design.
  • JVM tools don't get domain filtering. The java profile ships with enableProxy: false on both macOS and Linux, because JVM HTTP clients (Maven, Gradle's resolver, sbt's coursier) do not honour the standard HTTP_PROXY / HTTPS_PROXY env vars β€” they require -Dhttps.proxyHost system properties which sbe cannot inject dynamically (the proxy port is allocated at runtime). The kernel filter still pins TCP egress to port 443 (no random ports, no port-80 sneaking), but per-domain filtering is delegated to whatever network controls you run outside sbe.
  • Gradle on Linux requires opt-in. Gradle's CLI talks to a separate daemon over TCP on a kernel-chosen random localhost port. Landlock ABI v4 filters TCP by port only β€” there's no way to express "any port on 127.0.0.1" the way macOS SBPL can. Gradle does not accept a fixed daemon port via any CLI flag. To run Gradle under sbe on Linux, set allowAllNetwork: true for the java profile in your .sbe.yaml β€” this disables kernel TCP filtering entirely for that profile. sbt (default UDS IPC) and Maven (single-JVM) work without this opt-in. macOS users are unaffected (SBPL has (remote ip "localhost:*")).
  • DBus-resolved DNS may fail. Tools that resolve via systemd-resolved's DBus path (some Python/Node DNS libraries through nss-systemd) will hit EACCES on /run/dbus/system_bus_socket. The fallback through glibc's getaddrinfo over UDP (/etc/resolv.conf) works. Workaround for affected tools: use --allow-fetch or add the DBus socket to a custom profile.
  • Kernel <6.7 needs --allow-degraded. Without Landlock ABI v4, sbe cannot pin TCP egress to a specific port. With the flag, a best-effort seccomp connect() arg filter is used and a warning is printed. We refuse to silently downgrade.

Supported Ecosystems

Ecosystem Auto-detected commands Auto-detected files
Node.js node, npm, npx, yarn, pnpm, bun package.json
Rust cargo, rustc, rustup Cargo.toml
Python python, python3, pip, pip3, uv, poetry, pdm, rye pyproject.toml, setup.py, requirements.txt, Pipfile
Elixir mix, elixir, iex mix.exs
Java java, javac, mvn, mvnw, gradle, gradlew, sbt, scala, scalac, kotlinc pom.xml, build.gradle, build.gradle.kts, build.sbt

sbe profiles prints the full per-OS defaults.

Configuration

Create a .sbe.yaml (or .sbe.yml) in your project root, or ~/.config/sbe/config.yaml for global defaults:

profiles:
  node:
    allowWrite:
      - "./dist"
    allowDomains:
      - "api.mycompany.com"
    allowFetch:
      - "download.example.com"  # enables curl/wget + adds to proxy allowlist
    env:
      NODE_ENV: production

  # Custom profile extending an existing one
  my-app:
    extends: node
    allowDomains:
      - "internal-registry.mycompany.com"
    enableProxy: true
    allowAllNetwork: false
    allowDegraded: false        # Linux only; default false

Config resolution order (last wins):

  1. Built-in ecosystem defaults (per-OS YAML embedded at compile time)
  2. Global config: ~/.config/sbe/config.yaml
  3. Project config: .sbe.yaml or .sbe.yml (walks up to git root)
  4. CLI flags

Crate Structure

sbe/
β”œβ”€β”€ crates/
β”‚   β”œβ”€β”€ core/                       # sbe-core: profile + backends
β”‚   β”‚   └── src/
β”‚   β”‚       β”œβ”€β”€ profile/            # Per-ecosystem defaults (per-OS YAML)
β”‚   β”‚       β”‚   β”œβ”€β”€ defaults-macos.yaml
β”‚   β”‚       β”‚   └── defaults-linux.yaml
β”‚   β”‚       β”œβ”€β”€ sandbox/            # SandboxBackend trait + impls
β”‚   β”‚       β”‚   β”œβ”€β”€ mod.rs          # Trait + cfg-selected Sandbox re-export
β”‚   β”‚       β”‚   β”œβ”€β”€ macos/          # sandbox-exec backend
β”‚   β”‚       β”‚   β”‚   β”œβ”€β”€ mod.rs
β”‚   β”‚       β”‚   β”‚   β”œβ”€β”€ sbpl.rs
β”‚   β”‚       β”‚   β”‚   └── exec.rs
β”‚   β”‚       β”‚   └── linux/          # Landlock + seccomp backend
β”‚   β”‚       β”‚       β”œβ”€β”€ mod.rs
β”‚   β”‚       β”‚       β”œβ”€β”€ probe.rs    # Kernel/ABI probe
β”‚   β”‚       β”‚       β”œβ”€β”€ policy.rs   # YAML render for --dry-run
β”‚   β”‚       β”‚       β”œβ”€β”€ landlock.rs # Ruleset builder
β”‚   β”‚       β”‚       β”œβ”€β”€ seccomp.rs  # BpfProgram builder
β”‚   β”‚       β”‚       └── exec.rs     # pre_exec wiring
β”‚   β”‚       β”œβ”€β”€ config.rs
β”‚   β”‚       β”œβ”€β”€ detect.rs
β”‚   β”‚       └── error.rs
β”‚   └── proxy/                      # sbe-proxy: domain-filtering CONNECT proxy
β”œβ”€β”€ apps/
β”‚   └── cli/                        # sbe binary
└── specs/                          # Design documents

CLI Reference

sbe run [OPTIONS] -- <COMMAND>...

Options:
  -p, --profile <NAME>           Use a specific profile (overrides auto-detect)
  -n, --allow-domain <DOMAIN>    Add domain to network allowlist (repeatable)
  -N, --deny-domain <DOMAIN>     Remove domain from allowlist (repeatable)
  -w, --allow-write <PATH>       Add writable path (repeatable)
  -r, --deny-read <PATH>         Add read-denied path (repeatable)
  -e, --allow-exec <PATH>        Allow execution of binary (repeatable)
  -E, --deny-exec <PATH>         Deny execution of binary (repeatable; macOS only)
  -f, --allow-fetch <DOMAIN>     Allow build-time downloads (enables curl/wget + adds to proxy)
      --allow-all-network        Disable network sandboxing entirely
      --no-proxy                 Disable proxy (kernel port-443 mode)
      --allow-degraded           Proceed under a degraded kernel (Linux <ABI v4)
      --audit                    Stream sandbox violations to stderr
      --audit-log <PATH>         Write violations to file
      --dry-run                  Print policy to stdout, do not execute
  -c, --config <PATH>            Use specific config file
  -v, --verbose                  Verbose output
sbe inspect [OPTIONS] -- <COMMAND>...

  Print resolved config + generated policy without executing.
  macOS: SBPL Scheme document.
  Linux: YAML policy showing Landlock ruleset + seccomp action table.
sbe profiles

  List all built-in ecosystem profiles and their defaults.

Exit codes: sbe passes through the child process exit code. sbe's own errors use 125 (internal error) and 126 (sandbox setup failed).

How It Works

  1. Detect ecosystem from command name (npm β†’ Node) or project files (Cargo.toml β†’ Rust).
  2. Load profile β€” built-in per-OS defaults merged with global/project .sbe.yaml and CLI flags.
  3. Probe backend β€” sandbox-exec on macOS, Landlock ABI level on Linux. Refuse on missing capability unless --allow-degraded.
  4. Start proxy β€” bind HTTP CONNECT proxy on 127.0.0.1:0, get ephemeral port.
  5. Compile policy β€” SBPL string + tempfile on macOS; Landlock Ruleset + BpfProgram in-memory on Linux.
  6. Execute β€”
    • macOS: sandbox-exec -f /tmp/sbe-XXXX.sb <command> with HTTP_PROXY env injected.
    • Linux: Command::pre_exec issues prctl(PR_SET_NO_NEW_PRIVS) β†’ landlock_restrict_self β†’ seccomp(TSYNC), then execve. No tempfile on disk.
  7. Monitor β€” optionally stream violations (macOS sandboxd; Linux /dev/kmsg audit).
  8. Cleanup β€” stop proxy, propagate exit code.

Development

make build
make test          # uses cargo-nextest under sbe
make fmt
make lint
make check         # fmt + lint + test
make install

Requirements

  • Rust 2024 edition (stable)
  • macOS, or Linux β‰₯5.13 (β‰₯6.7 for full network parity)
  • cargo-nextest for make test (optional)

License

This project is distributed under the terms of MIT.

See LICENSE for details.

Copyright 2025-2026 Tyr Chen

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors