Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 41 additions & 4 deletions .github/workflows/QA.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,33 @@ env:
CARGO_TERM_COLOR: always

jobs:
# sx has two sandbox backends (Seatbelt on macOS, Landlock on Linux), so both
# platforms are first-class in CI.
test:
runs-on: macos-latest
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [macos-latest, ubuntu-latest]
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Report sandbox capabilities
run: |
if [ "$RUNNER_OS" = "Linux" ]; then
cat /sys/kernel/security/lsm 2>/dev/null || echo "securityfs not mounted"
echo "max_user_namespaces=$(cat /proc/sys/user/max_user_namespaces 2>/dev/null)"
fi
- name: Run tests
run: cargo test --verbose

lint:
runs-on: macos-latest
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [macos-latest, ubuntu-latest]
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
Expand All @@ -30,10 +46,15 @@ jobs:
- name: Check formatting
run: cargo fmt --check
- name: Run clippy
run: cargo clippy -- -D warnings
# --all-targets so tests and benches are linted too, not just the crate.
run: cargo clippy --all-targets -- -D warnings

build:
runs-on: macos-latest
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [macos-latest, ubuntu-latest]
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
Expand All @@ -43,6 +64,22 @@ jobs:
- name: Build release
run: cargo build --release --verbose

# End-to-end check that the sandbox actually denies what it claims.
security-behaviour:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [macos-latest, ubuntu-latest]
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Run security verification
env:
SX_SKIP_TEST_SUITE: "1" # covered by the `test` job
run: bash scripts/test-security.sh

# SCA: Dependency vulnerability scanning
security-audit:
runs-on: ubuntu-latest
Expand Down
33 changes: 26 additions & 7 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,14 @@ jobs:
fi
echo "Tag v${{ steps.validate.outputs.version }} is available"

# Test gate: ensure code compiles and tests pass
# Test gate: ensure code compiles and tests pass on both sandbox backends
test:
runs-on: macos-latest
runs-on: ${{ matrix.os }}
needs: validate
strategy:
fail-fast: false
matrix:
os: [macos-latest, ubuntu-latest]
steps:
- name: Checkout
uses: actions/checkout@v7
Expand Down Expand Up @@ -169,16 +173,22 @@ jobs:
git push origin main
git push origin "v${{ needs.validate.outputs.version }}"

# Build release binaries for macOS (Intel and Apple Silicon)
# Build release binaries for macOS (Intel, Apple Silicon) and Linux (x86_64, arm64)
build:
runs-on: macos-latest
runs-on: ${{ matrix.os }}
needs: [validate, prepare]
strategy:
fail-fast: false
matrix:
target:
- x86_64-apple-darwin
- aarch64-apple-darwin
include:
- target: x86_64-apple-darwin
os: macos-latest
- target: aarch64-apple-darwin
os: macos-latest
- target: x86_64-unknown-linux-gnu
os: ubuntu-latest
- target: aarch64-unknown-linux-gnu
os: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
Expand All @@ -190,6 +200,15 @@ jobs:
with:
targets: ${{ matrix.target }}

# Cross-link arm64 from the x86_64 runner rather than depending on the
# availability of hosted arm64 runners.
- name: Install arm64 cross linker
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: |
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu
echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> "$GITHUB_ENV"

- name: Cache cargo
uses: actions/cache@v5
with:
Expand Down
15 changes: 14 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
`sx` (sandbox-shell) is a Rust CLI that wraps shell sessions and commands in macOS Seatbelt sandboxes. It protects developers from malicious code in npm packages, untrusted repositories, and build scripts by restricting filesystem and network access.
`sx` (sandbox-shell) is a Rust CLI that wraps shell sessions and commands in a kernel sandbox - Seatbelt on macOS, Landlock on Linux. It protects developers from malicious code in npm packages, untrusted repositories, and build scripts by restricting filesystem and network access.

**Architecture**: config/profiles/CLI are platform-neutral and produce a `SandboxParams`. `sandbox::backend` turns that into a launcher: `sandbox-exec -f <profile>` on macOS, `sx --sandbox-apply <spec>` (a re-exec of this binary) on Linux. Both backends are compiled on every target so the macOS code stays type-checked by Linux CI; only the selection in `backend.rs` is target-specific.

**Critical Seatbelt Rules**:
1. Root literal `(allow file-read* (literal "/"))` is required for path traversal - processes need to read `/` to resolve paths
2. Seatbelt uses last-match-wins semantics when rules have matching filter types - deny rules after allow rules take precedence for nested paths (e.g., allow `/home` then deny `/home/.ssh`)
3. `(allow file-read-metadata)` must be global (no path filter) - required for `getaddrinfo()` DNS resolution to work. Without this, `curl`, Python, and other tools using the system resolver fail with "Could not resolve host" even when network is allowed. Commands like `host` and `nslookup` work without it because they use direct DNS UDP queries.

**Critical Landlock Rules**:
1. Landlock is **allow-list only** - there are no deny rules. `deny_read` is emulated by subtraction in `sandbox::linux::rules`: an allowed hierarchy containing a denied path is expanded into its siblings. Listing is granted on the hierarchy (so `ls ~` works), file contents are carved out
2. Landlock rules reference **resolved paths at policy-build time** - globs are expanded once, and paths created later do not match. Seatbelt regexes are evaluated at access time. Always resolve a path before turning it into a rule: rules bind to the inode, so a symlink would grant access to its target. A `deny_read` glob keeps its directory carved even when it currently matches nothing, so later files stay outside every rule
3. Do **not** handle `AccessFs::IoctlDev` - leaving it unhandled keeps device ioctls implicitly allowed, which is what terminal control needs (mirrors Seatbelt's global `(allow file-ioctl)`)
4. Landlock cannot express the network modes (ABI 4 filters TCP by port, not address). Network isolation uses a user+network namespace, with a seccomp filter as the fallback where unprivileged user namespaces are blocked
5. Always fail closed: if Landlock reports `NotEnforced`, or no network mechanism can be applied, refuse to run rather than execute unsandboxed
6. The `--sandbox-apply` helper runs in a freshly exec'd, single-threaded process. `unshare(CLONE_NEWUSER)` requires that, and it avoids allocating between `fork` and `exec`
7. The policy reaches the helper through its **environment**, never a file: the sandbox can write to `/tmp`, so a policy file there is swappable between write and read
8. Once `unshare` succeeds the namespace cannot be left, so a later setup failure is unrecoverable - hard-fail instead of falling back to seccomp in a half-built namespace

**Configuration Options**:
- `inherit_base = false` in `.sandbox.toml` skips the base profile for full custom control over allowed paths
- Profiles support `[platform.macos.*]` / `[platform.linux.*]` overlays, folded into the shared fields when the profile loads

## Programming Rules

Expand Down
43 changes: 41 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 7 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ name = "sx"
version = "1.0.3"
edition = "2021"
authors = ["Pierre Tomasina"]
description = "Lightweight sandbox for macOS development"
description = "Lightweight sandbox for macOS and Linux development"
license = "MIT"
repository = "https://github.com/agentic-dev3o/sandbox-shell"
keywords = ["sandbox", "security", "macos", "seatbelt"]
keywords = ["sandbox", "security", "seatbelt", "landlock"]
categories = ["command-line-utilities", "development-tools"]

[dependencies]
Expand Down Expand Up @@ -34,6 +34,11 @@ tempfile = "3"
signal-hook = "0.4"
libc = "0.2"

# Linux sandbox backend
[target.'cfg(target_os = "linux")'.dependencies]
landlock = "0.4"
glob = "0.3"

[dev-dependencies]
assert_cmd = "2"
predicates = "3"
Expand Down
41 changes: 31 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
# sx - macOS Sandbox CLI for Secure Development
# sx - Sandbox CLI for Secure Development

[![QA](https://github.com/agentic-dev3o/sandbox-shell/actions/workflows/QA.yaml/badge.svg)](https://github.com/agentic-dev3o/sandbox-shell/actions/workflows/QA.yaml)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![macOS](https://img.shields.io/badge/platform-macOS-lightgrey.svg)](https://developer.apple.com/documentation/security/app_sandbox)
[![platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey.svg)](#platform-support)

A lightweight Rust CLI that wraps shell commands in macOS Seatbelt sandboxes. That npm package you just installed? It can't read your `~/.ssh` keys or `~/.aws` credentials. Can't steal what you can't see.
A lightweight Rust CLI that wraps shell commands in a kernel sandbox — Seatbelt on macOS, Landlock on Linux. That npm package you just installed? It can't read your `~/.ssh` keys or `~/.aws` credentials. Can't steal what you can't see.

Supply chain attacks are everywhere. A single compromised dependency tries to exfiltrate your secrets? It can't—filesystem is deny-by-default. Your credentials aren't readable, even with network enabled. No containers, no VMs, just native macOS sandboxing.
Supply chain attacks are everywhere. A single compromised dependency tries to exfiltrate your secrets? It can't—filesystem is deny-by-default. Your credentials aren't readable, even with network enabled. No containers, no VMs, just the sandboxing your kernel already ships.

## Quick Start

```bash
# macOS
brew tap agentic-dev3o/sx
brew install sx

# Linux - download a release binary, or build from source
cargo install --git https://github.com/agentic-dev3o/sandbox-shell

# That's it. Now run untrusted code:
sx -- npm run build
sx -- cargo test
Expand Down Expand Up @@ -86,7 +90,24 @@ cd sandbox-shell
cargo install --path .
```

Requires macOS and Rust 1.70+.
Requires Rust 1.70+, plus one of:

- **macOS** 10.15+ (Seatbelt)
- **Linux** 5.13+ with Landlock enabled (`landlock` must appear in `/sys/kernel/security/lsm`). Kernel 6.12+ is recommended for the full rule set.

## Platform Support

`sx` uses the sandbox your kernel provides. The CLI, profiles, and config format are identical on both.

| | macOS | Linux |
|---|---|---|
| Filesystem | Seatbelt (`sandbox-exec`) | Landlock LSM |
| Network `offline` | Seatbelt network rules | network namespace, or seccomp where user namespaces are blocked |
| Network `localhost` | host loopback | the sandbox's own private loopback |
| `--trace` | unified log stream | unavailable (denials are only in the privileged audit log) |
| `allow_exec_sugid` | per-binary opt-in | not applicable — setuid never elevates |

Run `sx --explain` to see what the current machine will actually enforce. Behavioural differences are documented in [docs/SECURITY.md](docs/SECURITY.md#platform-differences).

## Configuration

Expand Down Expand Up @@ -139,7 +160,7 @@ allow_write = ["/tmp/build"]
pass_env = ["NODE_ENV", "DEBUG"]
```

Custom profiles go in `~/.config/sx/profiles/name.toml`. They support filesystem paths, env vars, exec sugid, and raw seatbelt rules for advanced sandbox operations. See [docs/PROFILES.md](docs/PROFILES.md).
Custom profiles go in `~/.config/sx/profiles/name.toml`. They support filesystem paths, env vars, exec sugid, per-OS sections, and raw seatbelt rules for advanced macOS operations. See [docs/PROFILES.md](docs/PROFILES.md).

## Usage

Expand All @@ -162,7 +183,7 @@ sx bun online -- bun install
# Debug what's blocked
sx --trace -- cargo build # Real-time violation log
sx --explain rust # Show allowed/denied
sx --dry-run rust # Preview seatbelt profile
sx --dry-run rust # Preview the generated policy
```

### Options
Expand All @@ -171,9 +192,9 @@ sx --dry-run rust # Preview seatbelt profile
|--------|-------------|
| `-v, --verbose` | Show sandbox configuration |
| `-d, --debug` | Log all denials |
| `-t, --trace` | Real-time violation stream |
| `--trace-file <PATH>` | Write trace to file |
| `-n, --dry-run` | Print profile, don't execute |
| `-t, --trace` | Real-time violation stream (macOS only) |
| `--trace-file <PATH>` | Write trace to file (macOS only) |
| `-n, --dry-run` | Print the policy, don't execute |
| `-c, --config <PATH>` | Use specific config |
| `--no-config` | Ignore all configs |
| `--explain` | Show what's allowed/denied |
Expand Down
Loading