diff --git a/.github/workflows/QA.yaml b/.github/workflows/QA.yaml index cb5057b..8e9e48a 100644 --- a/.github/workflows/QA.yaml +++ b/.github/workflows/QA.yaml @@ -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 @@ -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 @@ -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 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 94e5753..e6eca8a 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -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 @@ -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 @@ -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: diff --git a/CLAUDE.md b/CLAUDE.md index ee4e0c0..c878042 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ` on macOS, `sx --sandbox-apply ` (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 diff --git a/Cargo.lock b/Cargo.lock index df6a2e3..db4e3e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -63,9 +63,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "assert_cmd" @@ -185,6 +185,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -239,6 +259,12 @@ dependencies = [ "wasip2", ] +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "hashbrown" version = "0.16.1" @@ -267,6 +293,17 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "landlock" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cca98e95f35b29d469dade6724c6f96cec9236640f745a0e99b0334ec320ab1" +dependencies = [ + "enumflags2", + "libc", + "thiserror", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -568,6 +605,8 @@ dependencies = [ "assert_cmd", "clap", "dirs", + "glob", + "landlock", "libc", "predicates", "serde", diff --git a/Cargo.toml b/Cargo.toml index 4da9c8e..0383266 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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] @@ -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" diff --git a/README.md b/README.md index 31778d6..50aa337 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 ` | Write trace to file | -| `-n, --dry-run` | Print profile, don't execute | +| `-t, --trace` | Real-time violation stream (macOS only) | +| `--trace-file ` | Write trace to file (macOS only) | +| `-n, --dry-run` | Print the policy, don't execute | | `-c, --config ` | Use specific config | | `--no-config` | Ignore all configs | | `--explain` | Show what's allowed/denied | diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 322f862..6157938 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -10,7 +10,8 @@ Your personal paths. Terminal, shell prompt, directory jumper… [sandbox] default_network = "offline" # offline | online | localhost default_profiles = ["base"] # always include these -shell = "/bin/zsh" # shell inside sandbox +shell = "/bin/zsh" # shell inside sandbox (defaults to $SHELL, + # then /bin/zsh on macOS, /bin/bash on Linux) prompt_indicator = true # show [sx:mode] in prompt inherit_base = true # include base profile # allow_exec_sugid = ["/bin/ps"] # allow specific setuid/setgid binaries @@ -24,7 +25,7 @@ allow_read = [ # zoxide "~/.local/share/zoxide/", - # Ghostty users - required or terminal breaks + # Ghostty users - required or terminal breaks (macOS path shown) "/Applications/Ghostty.app/Contents/Resources/terminfo", ] allow_write = [ @@ -135,3 +136,65 @@ sx --allow-exec-sugid /bin/ps --allow-exec-sugid /usr/bin/newgrp -- ps aux - `AWS_*` - matches `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`… - `*_SECRET*` - matches `DATABASE_SECRET`, `MY_SECRET_KEY`… - `*_KEY` - matches `API_KEY`, `SSH_KEY`… + +## First Run: Prompt and Shell Tooling + +`sx` is deny-by-default, and that includes the tools your shell starts. Cache +directories are readable but **not writable** on either platform, so anything +that logs or caches per session will complain the first time you run `sx` with +no global config: + +``` +Unable to open session log file "~/.cache/starship/session_....log": Permission denied +``` + +That is the sandbox working. Grant the specific paths your prompt and shell +hooks need in `~/.config/sx/config.toml`: + +```toml +[filesystem] +allow_read = [ + "~/.config/starship.toml", # prompt config + "~/.config/mise", # version manager config + "~/.local/share/mise", # installed toolchains + shims + "~/.local/state/mise", + "~/.local/share/zoxide", # directory jumper +] +allow_write = [ + "~/.cache/starship", # per-session prompt log + "~/.cache/mise", + "~/.local/state/mise", + "~/.local/share/zoxide", +] +``` + +macOS equivalents live under `~/Library/Caches/` and +`~/Library/Application Support/` — see the example at the top of this file. + +Keep these grants narrow. `allow_write = ["~/.cache"]` works, but a cache is a +place tools later execute from, so widening it hands a compromised dependency a +persistence foothold. Grant the individual directories instead. + +If something breaks and the cause is not obvious, `sx --explain` prints every +resolved path, and `sx --dry-run` prints the policy itself. + +## Per-OS Configuration + +The config format is identical on macOS and Linux, but the paths are not. Custom +**profiles** support `[platform.macos]` / `[platform.linux]` sections for paths +that only exist on one OS — see [PROFILES.md](PROFILES.md#per-os-sections). + +For machine-specific paths in your global config, the simplest approach is to +keep the config next to the machine it describes: `~/.config/sx/config.toml` is +not shared between your Mac and your Linux box. + +Settings that behave differently per platform: + +| Setting | Note | +|---------|------| +| `allow_exec_sugid` | macOS only; on Linux setuid binaries never elevate | +| `[seatbelt] raw` | macOS only; ignored on Linux | +| `allow_list_dirs` | macOS lists exactly the named directory; Linux also lists nested directories (names only) | +| `deny_read` | on Linux, denies file contents; names may remain listable via a readable parent | + +Run `sx --explain` to see exactly what the current machine will enforce. diff --git a/docs/PROFILES.md b/docs/PROFILES.md index a14170a..6865436 100644 --- a/docs/PROFILES.md +++ b/docs/PROFILES.md @@ -7,11 +7,15 @@ Profiles are composable sandbox configs. Stack them: `sx online rust -- cargo bu ### base Always included (unless `inherit_base = false`). Provides: -- Read access to system directories (`/usr`, `/bin`, `/sbin`, `/Library`, `/System`) +- Read access to system directories (`/usr`, `/bin`, `/sbin`, `/opt`, `/etc`) - Read access to shell configs (`~/.zshrc`, `~/.bashrc`…) -- Write access to `/tmp` and session temp dir +- Write access to `/tmp` - Basic env vars (`TERM`, `PATH`, `HOME`, `USER`, `SHELL`) +Plus per-OS additions: +- **macOS:** `/Library`, `/System`, `/private/*`, safe `~/Library` subdirectories, the session temp dir +- **Linux:** `/lib`, `/lib64`, `/proc`, `/sys`, `/var/tmp`, `/dev/shm`, `~/.cache`, and git's XDG config + **Always denied** (even if you allow `~`): - `~/.ssh` - `~/.aws` @@ -116,6 +120,30 @@ Use it: sx mycompany -- ./run.sh ``` +### Per-OS Sections + +A profile can carry additions that only apply on one platform. Everything +outside `[platform.*]` applies everywhere; the matching overlay is merged in +when the profile loads, so nothing downstream has to think about platforms. + +```toml +# ~/.config/sx/profiles/mytool.toml +[filesystem] +allow_read = ["~/.mytool"] # both platforms + +[platform.macos.filesystem] +allow_read = ["~/Library/Caches/mytool"] + +[platform.linux.filesystem] +allow_read = ["~/.cache/mytool"] +``` + +`network_mode`, `filesystem` and `shell` can all be overridden per platform. +Lists are merged (union); `network_mode` replaces the shared value. + +Check the result with `sx --dry-run mytool` — it prints the policy for the +platform you are on. + ### Raw Seatbelt Rules For advanced use cases (IOKit, Mach services, app bundles), custom profiles support raw seatbelt rules: @@ -141,6 +169,9 @@ allow_write = ["~/Library/Caches/ms-playwright/"] Raw rules are appended verbatim to the generated seatbelt profile. Use `sx --dry-run myprofile` to verify the output. +Raw seatbelt rules are macOS-only and are ignored on Linux; `sx --dry-run` notes +this when a profile carries them. + ### Profile Resolution Order When you specify a profile name, `sx` searches in this order: @@ -166,3 +197,4 @@ profiles = ["rust", "localhost"] 3. **Env vars:** union of pass/deny lists 4. **Exec sugid:** path lists are unioned; mixing paths and booleans → last wins 5. **Seatbelt raw rules:** concatenated from all profiles in order +6. **Per-OS sections:** merged into the profile's shared fields before any of the above diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 18ca6b7..95d08fc 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -1,6 +1,7 @@ # Security Model -`sx` uses macOS Seatbelt (`sandbox-exec`) to isolate processes. Deny-by-default. +`sx` isolates processes with the sandbox the kernel already provides — Seatbelt +(`sandbox-exec`) on macOS, Landlock on Linux. Deny-by-default on both. ## Threat Model @@ -42,6 +43,30 @@ Everything blocked unless explicitly allowed: Everything else (`~/.config/gh`, `~/.netrc`, `~/.gnupg`…) is blocked by deny-by-default. Use profiles like `gpg` to allow specific paths. +#### The working directory is an exception + +The working directory gets full read/write access, and that grant is applied +*after* the deny rules on both backends. A deny that lives inside it therefore +has no effect. This is deliberate — a project under `~/Documents` still has to +build — but it has a sharp edge: + +```bash +cd ~ && sx # working directory is $HOME, so every deny above is void +``` + +Run from `$HOME`, the sandbox no longer protects `~/.ssh` or `~/.aws` from the +code it runs. `sx` prints a warning when this happens, and `sx --explain` marks +the affected entries `(OVERRIDDEN by the working directory)`. Run `sx` from the +project you actually want to sandbox, not from your home directory. + +#### `.sandbox.toml` is trusted input + +Project config is read from the working directory, which the sandbox grants +write access to. Code running under `sx` can therefore rewrite `.sandbox.toml` +and widen its own policy on the *next* run — the same trust you already extend +to a `Makefile` or an npm `postinstall`. Review it like one, especially in +repositories you did not write. + ### Network Isolation | Mode | Effect | @@ -74,6 +99,121 @@ Blocked by default: - `*_PASSWORD*` - `*_KEY` +Dynamic-loader variables (`LD_*`, `DYLD_*`) are dropped unconditionally and +cannot be re-added through `set_env`. They inject code into a process before its +`main` runs — including the sandbox launcher itself, where a preloaded library +could stop the policy from being applied at all. + +## Linux Enforcement + +Linux has no single mechanism equivalent to Seatbelt, so `sx` composes two. + +### Filesystem: Landlock + +Landlock is an unprivileged, per-process LSM policy that survives `exec` and is +inherited by every descendant. Like Seatbelt — and unlike a mount namespace — it +does not change the filesystem *view*: denied paths still exist and return +`EACCES`, so error messages stay recognisable. + +`sx` refuses to run if the kernel reports that Landlock was not enforced. A +sandbox that silently does not sandbox is worse than no sandbox. + +Access rights are requested at ABI 3 (the v1 file rights plus `Refer` for +cross-directory renames and `Truncate`), on a best-effort basis so older kernels +still get everything they support. `IoctlDev` (ABI 5) is deliberately left +unhandled, which keeps device ioctls implicitly allowed and mirrors Seatbelt's +global `(allow file-ioctl)` — without it, terminal control breaks. Signal +scoping (ABI 6) is requested, matching Seatbelt's `(allow signal (target self))`. + +### Handing the policy to the launcher + +The launcher receives the policy through its environment, set by `sx` at +`execve` time. It is deliberately not a file: the sandbox grants write access to +`/tmp`, so a policy file there could be swapped by a concurrent sandboxed +process between the moment `sx` writes it and the moment the launcher reads it. +An environment set by the parent has no such window. The launcher clears the +variable before `exec`, so the sandboxed program never sees it. + +### Emulating `deny_read` + +Landlock is **allow-list only**: it has no deny rules and no last-match-wins +ordering. `sx` emulates denies by *subtraction* — when an allowed hierarchy +contains a denied path, the hierarchy is expanded into its siblings so the +denied subtree simply never receives a rule. + +``` +allow_read = ["~"], deny_read = ["~/.aws"] + + becomes rules for ~/projects, ~/.bashrc, ~/dev, ... (everything but ~/.aws) + plus listing on ~ +``` + +A directory that cannot be enumerated contributes no rules — it fails closed. + +Two details that matter for correctness: + +- **Symlinks are resolved before a rule is written.** Landlock registers rules + against the inode a path resolves to, so a link named outside a denied subtree + would otherwise hand that subtree straight back. +- **A `deny_read` glob keeps its directory carved even when it matches nothing + yet.** Patterns are resolved once, so a directory containing a deny pattern is + always expanded entry by entry; a file created afterwards falls outside every + rule instead of inheriting a broad grant. + +A consequence of Landlock's design: a symlink *inside* an allowed directory that +points outside it is not reachable, because the target is not beneath any rule. +Allow the target path explicitly if you need it. + +### Network: namespaces, or seccomp + +Landlock only gained TCP controls in ABI 4, and they filter by port rather than +address, so they cannot express `sx`'s network modes. Network isolation uses +namespaces instead: + +| Mode | Mechanism | +|------|-----------| +| `online` | no restriction | +| `offline` | empty network namespace; seccomp rejecting `AF_INET`/`AF_INET6`/`AF_PACKET` sockets where unprivileged user namespaces are blocked | +| `localhost` | network namespace with its own loopback interface | + +If neither mechanism can be applied, `sx` refuses to run rather than execute an +"offline" command with live network access. + +### Setuid on Linux + +Unprivileged Landlock requires `no_new_privs`, so a setuid binary executed +inside the sandbox never elevates. This is stricter than the macOS default, and +it means `allow_exec_sugid` has no effect on Linux — `--dry-run` and `--explain` +say so when it is configured. + +## Platform Differences + +Same CLI, same config, same deny-by-default model. These behaviours differ: + +| Behaviour | macOS | Linux | +|-----------|-------|-------| +| `deny_read` and directory listings | names and contents both denied | contents denied; **names may still be listable** when a parent grants listing, because Landlock rules are always hierarchical | +| `localhost` network | filters the *host* loopback, so a sandboxed server is reachable from the host | the sandbox gets its **own private loopback**; sandboxed processes reach each other, host-local services stay out of reach (strictly tighter, but a real difference) | +| Glob paths (`/tmp/foo*`) | matched at access time | resolved once when the policy is built; paths created later do not match | +| `allow_list_dirs` | exactly the named directory | the directory and everything beneath it (names only) | +| `allow_exec_sugid` | per-binary opt-in | no effect; setuid never elevates | +| Raw `[seatbelt]` rules | applied | ignored | +| Supplementary groups | unchanged | dropped in `offline`/`localhost`, because entering a user namespace maps only your own uid/gid | +| `--trace` | streams denials from the unified log | unavailable; Landlock denials only reach the kernel audit log, which needs privileges. Use `--dry-run` / `--explain` instead | + +### `/proc` exposure on Linux + +The Linux base profile grants read access to `/proc`, which most runtimes +require. That also exposes `/proc//environ` and `/proc//cmdline` for +**your own other processes**, so secrets exported into an unrelated shell are +readable from inside the sandbox. + +This is comparable to macOS, where the profile grants `(allow sysctl-read)`. +Narrowing it needs a PID namespace with a private `/proc` mount, which is a +container-shaped change and is not done today. If it matters to you, avoid +exporting long-lived secrets into your interactive shell environment, or drop +`/proc` from `allow_read` and add back the specific files your tools need. + ## Generated Seatbelt Profile ```scheme @@ -110,16 +250,39 @@ Blocked by default: ; online: (allow network*) ``` +## Generated Landlock Policy + +`sx --dry-run` prints the resolved rule set, the network plan for this machine, +and the paths that were denied: + +``` +# sx sandbox policy (landlock) +# kernel Landlock ABI: 6 +# network: offline -> private network namespace +# setuid/setgid execution: never elevates (no_new_privs is always set) + +# denied for reading (no rule is emitted for these paths) +# deny /home/me/.aws + +# r = read files + execute, l = list directory, w = create/modify/delete +rl- /usr +rl- /proc +rlw /home/me/project +r-w /dev/null +``` + ## Limitations 1. **Root bypass** - Root can escape any sandbox 2. **Kernel bugs** - Sandbox depends on kernel security 3. **Side channels** - Timing attacks not prevented 4. **Existing processes** - Only affects new processes +5. **Same-user process introspection** (Linux) - see [`/proc` exposure](#proc-exposure-on-linux) ## Best Practices 1. Default to `offline` unless network required 2. Use `localhost` for dev servers 3. Review custom profiles before trusting them -4. Use `--trace` to debug denials +4. Use `--trace` to debug denials (macOS), or `--dry-run` / `--explain` (both) +5. On Linux, check `sx --explain` reports a Landlock ABI — if it does not, the kernel cannot enforce the sandbox diff --git a/profiles/base.toml b/profiles/base.toml index 52a9f89..d5e05ff 100644 --- a/profiles/base.toml +++ b/profiles/base.toml @@ -1,5 +1,9 @@ # Base sandbox profile - minimal security defaults # Always included unless explicitly excluded +# +# Paths outside [platform.*] apply to every OS. Per-OS additions live in +# [platform.macos.*] / [platform.linux.*] and are merged into the lists above +# when the profile is loaded. network_mode = "offline" @@ -11,26 +15,14 @@ allow_read = [ "/sbin", "/opt", "/etc", - "/private/etc", - "/Library", - "/System", - "/private/var/db", - "/private/var/folders", - "/private/var/run", - "/var/db", - "/var/folders", - "/var/run", "/tmp", # sx config "~/.config/sx", - # ~/Library - allowlist only safe subdirectories (shift-left) - "~/Library/Caches/", - "~/Library/Preferences/", - "~/Library/Application Support/", - "~/Library/Logs/", - "~/Library/Frameworks/", - "~/Library/Keychains/", # Encrypted, requires Security framework ACL to access secrets - "~/Library/Developer/", + # Git treats a permission error on its user config as fatal, so + # `sx -- git status` needs these. Read-only, and not where git keeps + # credentials - ~/.git-credentials stays denied by default. + "~/.gitconfig", + "~/.config/git", # Shell config files (read-only) "~/.zshrc", "~/.zshenv", @@ -51,8 +43,6 @@ deny_read = [ ] allow_write = [ "/tmp", - "/private$TMPDIR", # Session-specific temp dir (canonical path) - "$TMPDIR", # Session-specific temp dir (symlink path) ] [shell] @@ -74,3 +64,52 @@ deny_env = [ "*_PASSWORD*", "*_KEY", ] + +[platform.macos.filesystem] +allow_read = [ + "/private/etc", + "/Library", + "/System", + "/private/var/db", + "/private/var/folders", + "/private/var/run", + "/var/db", + "/var/folders", + "/var/run", + # ~/Library - allowlist only safe subdirectories (shift-left) + "~/Library/Caches/", + "~/Library/Preferences/", + "~/Library/Application Support/", + "~/Library/Logs/", + "~/Library/Frameworks/", + "~/Library/Keychains/", # Encrypted, requires Security framework ACL to access secrets + "~/Library/Developer/", +] +allow_write = [ + "/private$TMPDIR", # Session-specific temp dir (canonical path) + "$TMPDIR", # Session-specific temp dir (symlink path) +] + +[platform.linux.filesystem] +allow_read = [ + # Dynamic loader and shared libraries (usually symlinks into /usr) + "/lib", + "/lib64", + # Kernel interfaces. /proc is needed by most runtimes; docs/SECURITY.md + # explains what it exposes and why Landlock alone cannot narrow it. + "/proc", + "/sys", + # DNS: /etc/resolv.conf is a symlink into /run on systemd systems and + # Landlock matches on the resolved path. + "/run/systemd/resolve", + "/var/lib/dbus", + "/var/tmp", + # XDG cache - parity with ~/Library/Caches on macOS + "~/.cache", + "~/.terminfo", + "/dev/shm", +] +allow_write = [ + "/var/tmp", + "/dev/shm", +] diff --git a/profiles/bun.toml b/profiles/bun.toml index 25e62da..5fa8084 100644 --- a/profiles/bun.toml +++ b/profiles/bun.toml @@ -22,10 +22,11 @@ allow_write = [ "~/.bun", ] -# Allow listing parent directories for Bun's module resolution -# Uses Seatbelt 'literal' filter - only the exact directory is listable +# Allow listing parent directories for Bun's module resolution. +# macOS uses the Seatbelt 'literal' filter, so only the exact directory is +# listable. Landlock rules are hierarchical, so on Linux nested directories are +# listable too - names only, never file contents. allow_list_dirs = [ - "/Users", "~", ] @@ -35,3 +36,9 @@ pass_env = [ "NODE_ENV", "npm_config_registry", ] + +[platform.macos.filesystem] +allow_list_dirs = ["/Users"] + +[platform.linux.filesystem] +allow_list_dirs = ["/home"] diff --git a/profiles/claude.toml b/profiles/claude.toml index 5a45118..cc08a07 100644 --- a/profiles/claude.toml +++ b/profiles/claude.toml @@ -11,9 +11,6 @@ allow_read = [ "~/.local/state/claude", # XDG state dir for locks "~/.cache/claude", # XDG cache dir for staging "~/.local/bin/claude", - "~/.CFUserTextEncoding", - "/private/tmp/claude*", # claude-UID directories (e.g. claude-501) - "/private/tmp/zsh*", # When claude use zsh in bash it create dynamic tmp file ] allow_write = [ "~/.claude", @@ -22,11 +19,23 @@ allow_write = [ "~/.local/share/claude", "~/.local/state/claude", # XDG state dir for locks "~/.cache/claude", # XDG cache dir for staging - "~/Library/Caches/claude-cli-nodejs/", - "~/Library/Keychains/login.keychain-db", # OAuth token refresh - "/private/tmp/claude*", # claude-UID directories (e.g. claude-501) - "/private/tmp/zsh*", # When claude use zsh in bash it create dynamic tmp file ] [shell] pass_env = ["ANTHROPIC_API_KEY"] + +# macOS-only paths: per-session temp dirs live outside /tmp, and the login +# keychain backs OAuth token refresh. On Linux the base profile already grants +# /tmp and there is no keychain equivalent. +[platform.macos.filesystem] +allow_read = [ + "~/.CFUserTextEncoding", + "/private/tmp/claude*", # claude-UID directories (e.g. claude-501) + "/private/tmp/zsh*", # claude creates dynamic tmp files when running zsh +] +allow_write = [ + "~/Library/Caches/claude-cli-nodejs/", + "~/Library/Keychains/login.keychain-db", # OAuth token refresh + "/private/tmp/claude*", + "/private/tmp/zsh*", +] diff --git a/profiles/opencode.toml b/profiles/opencode.toml index f088b9e..0a78576 100644 --- a/profiles/opencode.toml +++ b/profiles/opencode.toml @@ -9,7 +9,6 @@ allow_read = [ "~/.local/state/opencode", "~/.config/opencode", "~/.cache/opencode", - "/private/tmp/.*", ] allow_write = [ @@ -18,14 +17,15 @@ allow_write = [ "~/.local/share/opencode", "~/.local/state/opencode", "~/.cache/opencode", - "/private/tmp/.*", -] - -# Directory listing permissions -allow_list_dirs = [ - "/private/tmp" ] [shell] # No additional environment variables needed -pass_env = [] \ No newline at end of file +pass_env = [] + +# macOS keeps per-session temp dirs outside /tmp; on Linux the base profile +# already covers /tmp. +[platform.macos.filesystem] +allow_read = ["/private/tmp/.*"] +allow_write = ["/private/tmp/.*"] +allow_list_dirs = ["/private/tmp"] diff --git a/scripts/test-security.sh b/scripts/test-security.sh index bef3346..c4a396c 100755 --- a/scripts/test-security.sh +++ b/scripts/test-security.sh @@ -1,6 +1,12 @@ #!/bin/bash # Security verification tests for sx sandbox -# Run this script to verify sandbox security properties +# +# Two layers: +# A. Policy shape - the generated policy says what we expect (platform-specific) +# B. Behaviour - the sandbox actually denies what it claims (platform-neutral) +# +# Layer B is the one that matters: a sandbox that silently fails to sandbox is +# worse than no sandbox at all. set -e @@ -12,129 +18,266 @@ NC='\033[0m' # No Color PASS_COUNT=0 FAIL_COUNT=0 -# Build the project first +PLATFORM=$(uname -s) +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + echo "Building sx..." cargo build --release 2>/dev/null || cargo build -SX_BIN="${SX_BIN:-./target/release/sx}" -if [ ! -f "$SX_BIN" ]; then - SX_BIN="./target/debug/sx" +SX_BIN="${SX_BIN:-$REPO_ROOT/target/release/sx}" +if [ ! -x "$SX_BIN" ]; then + SX_BIN="$REPO_ROOT/target/debug/sx" fi -if [ ! -f "$SX_BIN" ]; then +if [ ! -x "$SX_BIN" ]; then echo -e "${RED}Error: sx binary not found. Run 'cargo build' first.${NC}" exit 1 fi echo "" -echo "=== sx Security Verification Tests ===" +echo "=== sx Security Verification Tests ($PLATFORM) ===" echo "" -# Helper function for test results pass() { echo -e "${GREEN}✓ PASS${NC}: $1" - ((PASS_COUNT++)) + PASS_COUNT=$((PASS_COUNT + 1)) } fail() { echo -e "${RED}✗ FAIL${NC}: $1" - ((FAIL_COUNT++)) + FAIL_COUNT=$((FAIL_COUNT + 1)) } warn() { echo -e "${YELLOW}! WARN${NC}: $1" } -# Create temp directory for tests -TEMP_DIR=$(mktemp -d) -trap "rm -rf $TEMP_DIR" EXIT +skip() { + echo -e "${YELLOW}- SKIP${NC}: $1" +} + +# Workspace outside /tmp: the base profile grants /tmp on purpose, so a +# directory under $HOME is what "outside the sandbox" actually looks like. +WORK_ROOT=$(mktemp -d "$HOME/.sx-security-test.XXXXXX") +trap 'rm -rf "$WORK_ROOT"' EXIT +mkdir -p "$WORK_ROOT/work" "$WORK_ROOT/outside" "$WORK_ROOT/vault" +echo "TOPSECRET" > "$WORK_ROOT/outside/secret.txt" +echo "TOPSECRET" > "$WORK_ROOT/vault/key" +WORK="$WORK_ROOT/work" -# Test 1: Verify sandbox blocks reading ~/.ssh -echo "Test 1: Sandbox blocks ~/.ssh access" -if $SX_BIN --dry-run 2>/dev/null | grep -q ".ssh"; then - pass "Profile mentions .ssh in deny rules" +# Run sx with the working directory set to the sandbox workspace. +sx_in_work() { + (cd "$WORK" && "$SX_BIN" "$@") +} + +POLICY=$(cd "$WORK" && "$SX_BIN" --dry-run 2>/dev/null || echo "") + +# Which mechanisms this machine actually uses, so a green run says what it +# covered. On Linux the network path depends on whether user namespaces work. +echo "$POLICY" | grep -E "^# (kernel|network|setuid)" | sed 's/^# / /' +echo "" + +echo "--- A. Policy shape ---" + +# A1: sensitive home paths are denied +echo "Test A1: Sensitive paths appear in the deny rules" +SECRET_DIR=".ssh" +if echo "$POLICY" | grep -q "$SECRET_DIR"; then + pass "Policy denies $SECRET_DIR" else - warn "Could not verify .ssh deny rule in profile" + fail "Policy does not mention $SECRET_DIR" fi -# Test 2: Verify default network is offline -echo "Test 2: Default network mode is offline" -if $SX_BIN --dry-run 2>/dev/null | grep -q "Network disabled\|offline"; then +# A2: default network mode is offline +echo "Test A2: Default network mode is offline" +if echo "$POLICY" | grep -qi "Network disabled\|network: offline"; then pass "Default network mode is offline" else - warn "Could not verify offline mode" + fail "Default network mode is not offline" fi -# Test 3: Verify working directory gets full access -echo "Test 3: Working directory has full access" -cd "$TEMP_DIR" -echo "test content" > testfile.txt -if $SX_BIN --dry-run 2>/dev/null | grep -q "Working directory\|file\*"; then - pass "Working directory rules present in profile" +# A3: working directory gets full access +echo "Test A3: Working directory has full access" +case "$PLATFORM" in + Darwin) WD_PATTERN="Working directory\|file\*" ;; + *) WD_PATTERN="^rlw " ;; +esac +if echo "$POLICY" | grep -q "$WD_PATTERN"; then + pass "Working directory rules present in policy" else - warn "Could not verify working directory access" + fail "Working directory rules missing from policy" fi -# Test 4: Verify process execution is allowed -echo "Test 4: Process execution is allowed" -if $SX_BIN --dry-run 2>/dev/null | grep -q "process-fork\|process-exec"; then - pass "Process fork/exec allowed in profile" +# A4: deny-by-default model is in force +echo "Test A4: Deny-by-default model" +case "$PLATFORM" in + Darwin) DEFAULT_PATTERN="(deny default)" ;; + *) DEFAULT_PATTERN="sx sandbox policy (landlock)" ;; +esac +if echo "$POLICY" | grep -qF "$DEFAULT_PATTERN"; then + pass "Deny-by-default policy header present" else - fail "Process execution not allowed" + fail "Deny-by-default policy header missing" fi -# Test 5: Verify sensitive env vars are blocked -echo "Test 5: Sensitive environment variables are protected" -# This checks the profile loading, not actual env blocking -if cargo test --quiet profile 2>/dev/null | grep -q "ok"; then - pass "Profile tests pass (includes env var protection)" +# A5: temp directory is usable +echo "Test A5: Temporary directory access" +if echo "$POLICY" | grep -q "/tmp\|/var/folders"; then + pass "Temp directory rules present" else - warn "Could not verify env var protection" + fail "Temp directory rules missing" fi -# Test 6: Verify deny rules come before allow -echo "Test 6: Deny rules are present" -if $SX_BIN --dry-run 2>/dev/null | grep -q "(deny default)"; then - pass "Deny default rule present" -else - fail "Deny default rule missing" -fi +# A6: platform-specific policy validity +echo "Test A6: Policy is well-formed for this platform" +case "$PLATFORM" in + Darwin) + if echo "$POLICY" | grep -qF "(version 1)"; then + pass "Seatbelt profile has a valid header" + else + fail "Seatbelt profile missing version header" + fi + ;; + Linux) + if echo "$POLICY" | grep -q "kernel Landlock ABI: [1-9]"; then + pass "Landlock is supported and reported by the kernel" + else + fail "Kernel does not report Landlock support" + fi + ;; +esac + +echo "" +echo "--- B. Enforcement behaviour ---" -# Test 7: Verify profile composition works -echo "Test 7: Profile composition" -if cargo test --quiet compose_profiles 2>/dev/null; then - pass "Profile composition tests pass" +# Some macOS images (including GitHub-hosted runners) refuse custom +# deny-default Seatbelt profiles, so probe once before asserting behaviour. +# On Linux there is no such restriction: a failure here is a real failure. +if sx_in_work -- /bin/echo probe >/dev/null 2>&1; then + SANDBOX_RUNS=1 else - fail "Profile composition tests failed" + SANDBOX_RUNS=0 fi -# Test 8: Verify seatbelt syntax is valid -echo "Test 8: Seatbelt profile syntax validation" -PROFILE=$($SX_BIN --dry-run 2>/dev/null || echo "") -if [ -n "$PROFILE" ]; then - if echo "$PROFILE" | grep -q "(version 1)"; then - pass "Seatbelt profile has valid header" +if [ "$SANDBOX_RUNS" = "0" ]; then + if [ "$PLATFORM" = "Darwin" ]; then + skip "Custom Seatbelt profiles are unavailable on this system - behaviour tests skipped" else - fail "Seatbelt profile missing version header" + fail "The sandbox could not run a command at all" fi +fi + +run_behaviour_tests() { + +# B1: the sandbox runs commands at all +echo "Test B1: Commands execute inside the sandbox" +if sx_in_work -- /bin/echo sandboxed >/dev/null 2>&1; then + pass "Sandboxed command executed" else - warn "Could not generate profile for validation" + fail "Sandboxed command failed to execute" fi -# Test 9: Verify /tmp write access -echo "Test 9: Temporary directory access" -if $SX_BIN --dry-run 2>/dev/null | grep -q "/tmp\|/var/folders"; then - pass "Temp directory rules present" +# B2: the working directory is writable +echo "Test B2: Working directory is writable" +if sx_in_work -- /usr/bin/touch "$WORK/created.txt" >/dev/null 2>&1 && [ -f "$WORK/created.txt" ]; then + pass "Working directory is writable" else - warn "Could not verify temp directory access" + fail "Working directory is not writable" fi -# Test 10: Run all integration tests -echo "Test 10: Integration test suite" -if cargo test --test integration --quiet 2>/dev/null; then - pass "All integration tests pass" +# B3: home directory is not readable by default +echo "Test B3: Home directory is not readable by default" +if sx_in_work -- /bin/ls "$HOME" >/dev/null 2>&1; then + fail "Home directory was listable (deny-by-default is not in force)" else - fail "Integration tests failed" + pass "Home directory is not listable" +fi + +# B4: files outside the working directory are not readable +echo "Test B4: Files outside the sandbox are unreadable" +LEAK=$(sx_in_work -- /bin/cat "$WORK_ROOT/outside/secret.txt" 2>/dev/null || true) +if [ -z "$LEAK" ]; then + pass "File outside the sandbox stayed unreadable" +else + fail "Leaked file contents from outside the sandbox" +fi + +# B5: writes outside the working directory are blocked +echo "Test B5: Writes outside the sandbox are blocked" +sx_in_work -- /usr/bin/touch "$WORK_ROOT/outside/planted.txt" >/dev/null 2>&1 || true +if [ -f "$WORK_ROOT/outside/planted.txt" ]; then + fail "Wrote a file outside the sandbox" +else + pass "Write outside the sandbox was blocked" +fi + +# B6: deny_read overrides an explicit allow_read +echo "Test B6: deny_read overrides allow_read" +LEAK=$(sx_in_work --allow-read "$WORK_ROOT/vault" --deny-read "$WORK_ROOT/vault" \ + -- /bin/cat "$WORK_ROOT/vault/key" 2>/dev/null || true) +if [ -z "$LEAK" ]; then + pass "deny_read took precedence over allow_read" +else + fail "deny_read did not override allow_read" +fi + +# B7: restrictions survive fork and exec +echo "Test B7: Restrictions are inherited by child processes" +LEAK=$(sx_in_work -- /bin/sh -c "cat '$WORK_ROOT/outside/secret.txt'" 2>/dev/null || true) +if [ -z "$LEAK" ]; then + pass "Child process inherited the sandbox" +else + fail "Child process escaped the sandbox" +fi + +# B8: offline mode blocks the network +echo "Test B8: Offline mode blocks network access" +if sx_in_work -- /usr/bin/curl -sS -m 5 -o /dev/null https://example.com >/dev/null 2>&1; then + fail "Offline mode allowed a network connection" +else + pass "Offline mode blocked the network" +fi + +# B9: online mode still works (needs connectivity) +echo "Test B9: Online mode allows network access" +if ! curl -sS -m 5 -o /dev/null https://example.com >/dev/null 2>&1; then + skip "No outbound connectivity on this host" +elif sx_in_work online -- /usr/bin/curl -sS -m 10 -o /dev/null https://example.com >/dev/null 2>&1; then + pass "Online mode reached the network" +else + fail "Online mode could not reach the network" +fi + +# B10: Linux never lets a setuid binary elevate +if [ "$PLATFORM" = "Linux" ]; then + echo "Test B10: no_new_privs is set" + if sx_in_work -- /usr/bin/grep -q "NoNewPrivs:.1" /proc/self/status >/dev/null 2>&1; then + pass "no_new_privs is set inside the sandbox" + else + fail "no_new_privs is not set" + fi +fi + +} + +if [ "$SANDBOX_RUNS" = "1" ]; then + run_behaviour_tests +fi + +echo "" +echo "--- C. Test suites ---" + +# CI runs the suite in its own job; skip the duplicate there. +if [ -n "$SX_SKIP_TEST_SUITE" ]; then + skip "Test suite (run separately)" +else + echo "Test C1: Unit and integration test suite" + if cargo test --quiet >/dev/null 2>&1; then + pass "All tests pass" + else + fail "Test suite failed" + fi fi echo "" diff --git a/src/cli/args.rs b/src/cli/args.rs index 72c4af7..d2a6f38 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -4,10 +4,11 @@ use std::path::PathBuf; // Re-export NetworkMode from config schema to avoid duplication pub use crate::config::schema::NetworkMode; -/// sx - Lightweight sandbox for macOS development +/// sx - Lightweight sandbox for macOS and Linux development /// -/// Wraps shell sessions and commands in a macOS Seatbelt sandbox, -/// restricting filesystem and network access to protect the user's system. +/// Wraps shell sessions and commands in a macOS Seatbelt or Linux Landlock +/// sandbox, restricting filesystem and network access to protect the user's +/// system. #[derive(Parser, Debug)] #[command(name = "sx")] #[command(author, version, about, long_about = None)] @@ -29,13 +30,14 @@ pub struct Args { pub debug: bool, /// Trace sandbox violations (shows blocked operations in real-time). - /// Note: Shows violations from ALL sandboxed processes on the system, - /// not just this session (macOS limitation) + /// macOS only: shows violations from ALL sandboxed processes on the + /// system, not just this session. Unavailable on Linux, where Landlock + /// denials are only recorded in the privileged kernel audit log. #[arg(short, long)] pub trace: bool, - /// Write trace output to file instead of stderr. - /// Note: Shows violations from ALL sandboxed processes on the system + /// Write trace output to file instead of stderr (macOS only). + /// Shows violations from ALL sandboxed processes on the system #[arg(long, value_name = "PATH")] pub trace_file: Option, diff --git a/src/cli/commands.rs b/src/cli/commands.rs index 76a97ad..af0d6b0 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -14,8 +14,9 @@ use crate::config::{ ExecSugid, NetworkMode, Profile, }; use crate::detection::project_type::detect_project_types; +use crate::sandbox::backend; use crate::sandbox::executor::execute_sandboxed_with_trace; -use crate::sandbox::seatbelt::{generate_seatbelt_profile, SandboxParams}; +use crate::sandbox::params::SandboxParams; use crate::utils::paths::expand_paths; /// Initialize a .sandbox.toml config in the current directory @@ -41,6 +42,12 @@ pub fn explain(args: &Args) -> Result<()> { println!("=== Sandbox Configuration ===\n"); + println!("Backend: {}", backend::describe()); + for caveat in backend::caveats(&context.params) { + println!(" {}", caveat); + } + println!(); + // Network mode println!("Network Mode: {:?}", context.params.network_mode); println!(); @@ -73,9 +80,17 @@ pub fn explain(args: &Args) -> Result<()> { // Denied read paths if !context.params.deny_read.is_empty() { + let shadowed = denies_shadowed_by_working_dir(&context.params); println!("Denied Read Paths:"); for path in &context.params.deny_read { - println!(" - {}", path.display()); + if shadowed.contains(&path) { + println!( + " - {} (OVERRIDDEN by the working directory)", + path.display() + ); + } else { + println!(" - {}", path.display()); + } } println!(); } @@ -119,7 +134,7 @@ pub fn explain(args: &Args) -> Result<()> { .shell .clone() .or_else(|| env::var("SHELL").ok()) - .unwrap_or_else(|| "/bin/zsh".to_string()); + .unwrap_or_else(|| crate::shell::default_shell().to_string()); println!("Mode: Interactive shell ({})", shell); } @@ -129,8 +144,8 @@ pub fn explain(args: &Args) -> Result<()> { /// Print generated sandbox profile without executing pub fn dry_run(args: &Args) -> Result<()> { let context = build_sandbox_context(args)?; - let profile = generate_seatbelt_profile(&context.params) - .context("Failed to generate seatbelt profile")?; + let profile = + backend::render_policy(&context.params).context("Failed to generate sandbox policy")?; if args.verbose { println!("# Profiles: {}", context.profile_names.join(", ")); @@ -148,11 +163,17 @@ pub fn execute(args: &Args) -> Result<()> { let context = build_sandbox_context(args)?; if args.verbose { + eprintln!("[sx] Backend: {}", backend::describe()); + for caveat in backend::caveats(&context.params) { + eprintln!("[sx] {}", caveat); + } eprintln!("[sx] Network: {:?}", context.params.network_mode); eprintln!("[sx] Profiles: {}", context.profile_names.join(", ")); eprintln!("[sx] Working dir: {}", context.params.working_dir.display()); } + warn_about_shadowed_denies(&context.params); + let command: Vec = args.command.clone().unwrap_or_default(); let shell = context.config.sandbox.shell.as_deref(); @@ -304,7 +325,7 @@ fn build_sandbox_params( .shell .clone() .or_else(|| std::env::var("SHELL").ok()) - .unwrap_or_else(|| "/bin/zsh".to_string()); + .unwrap_or_else(|| crate::shell::default_shell().to_string()); let path_env = std::env::var("PATH").ok(); let shell_list_dirs = collect_interactive_shell_list_dirs(&home_dir, &shell_path, path_env.as_deref()); @@ -325,22 +346,10 @@ fn build_sandbox_params( } // Expand all paths - allow_read = expand_paths(&allow_read) - .into_iter() - .map(|p| p.to_string_lossy().to_string()) - .collect(); - deny_read = expand_paths(&deny_read) - .into_iter() - .map(|p| p.to_string_lossy().to_string()) - .collect(); - allow_write = expand_paths(&allow_write) - .into_iter() - .map(|p| p.to_string_lossy().to_string()) - .collect(); - allow_list_dirs = expand_paths(&allow_list_dirs) - .into_iter() - .map(|p| p.to_string_lossy().to_string()) - .collect(); + allow_read = expand_unique(&allow_read); + deny_read = expand_unique(&deny_read); + allow_write = expand_unique(&allow_write); + allow_list_dirs = expand_unique(&allow_list_dirs); // Build raw rules if present let raw_rules = profile.seatbelt.as_ref().and_then(|s| s.raw.clone()); @@ -370,6 +379,53 @@ fn build_sandbox_params( } } +/// Denied paths that the working directory silently overrides. +/// +/// The working directory is granted full access *after* the deny rules on both +/// backends, so a deny that lives inside it has no effect. That is intentional +/// (a project under `~/Documents` still has to build), but it also means +/// running `sx` straight from `$HOME` quietly voids every deny. Worth saying +/// out loud rather than letting the promise fail silently. +fn denies_shadowed_by_working_dir(params: &SandboxParams) -> Vec<&PathBuf> { + if params.working_dir.as_os_str().is_empty() { + return Vec::new(); + } + params + .deny_read + .iter() + .filter(|deny| deny.starts_with(¶ms.working_dir)) + .collect() +} + +fn warn_about_shadowed_denies(params: &SandboxParams) { + let shadowed = denies_shadowed_by_working_dir(params); + if shadowed.is_empty() { + return; + } + let list: Vec = shadowed.iter().map(|p| p.display().to_string()).collect(); + eprintln!( + "\x1b[33m[sx:warn]\x1b[0m Working directory {} has full access, which overrides \ + deny_read for: {}", + params.working_dir.display(), + list.join(", ") + ); +} + +/// Expand paths and drop duplicates, preserving order. +/// +/// Distinct entries can collapse onto the same path once symlinks are resolved: +/// on usr-merged Linux systems `/bin`, `/sbin` and `/lib` all land in `/usr`. +/// Duplicate rules are harmless to both backends but make `--explain` and +/// `--dry-run` noisy. +fn expand_unique(paths: &[String]) -> Vec { + let mut seen = HashSet::new(); + expand_paths(paths) + .into_iter() + .map(|p| p.to_string_lossy().to_string()) + .filter(|p| seen.insert(p.clone())) + .collect() +} + /// Determine network mode with precedence: CLI > profile > config fn determine_network_mode(args: &Args, profile: &Profile, config: &Config) -> NetworkMode { // CLI flags take highest precedence @@ -518,6 +574,39 @@ pass_env = [] mod tests { use super::*; + #[test] + fn deny_inside_the_working_directory_is_reported_as_shadowed() { + let params = SandboxParams { + working_dir: PathBuf::from("/home/u"), + deny_read: vec![ + PathBuf::from("/home/u/.aws"), + PathBuf::from("/home/other/.aws"), + ], + ..Default::default() + }; + let shadowed = denies_shadowed_by_working_dir(¶ms); + assert_eq!(shadowed, vec![&PathBuf::from("/home/u/.aws")]); + } + + #[test] + fn denies_outside_the_working_directory_are_not_shadowed() { + let params = SandboxParams { + working_dir: PathBuf::from("/home/u/project"), + deny_read: vec![PathBuf::from("/home/u/.aws")], + ..Default::default() + }; + assert!(denies_shadowed_by_working_dir(¶ms).is_empty()); + } + + #[test] + fn an_empty_working_directory_shadows_nothing() { + let params = SandboxParams { + deny_read: vec![PathBuf::from("/home/u/.aws")], + ..Default::default() + }; + assert!(denies_shadowed_by_working_dir(¶ms).is_empty()); + } + #[test] fn test_generate_config_template_is_valid_toml() { let template = generate_config_template(); @@ -570,8 +659,10 @@ mod tests { #[test] fn test_determine_network_mode_profile_precedence() { let args = Args::try_parse_from(["sx"]).unwrap(); - let mut profile = Profile::default(); - profile.network_mode = Some(NetworkMode::Localhost); + let profile = Profile { + network_mode: Some(NetworkMode::Localhost), + ..Default::default() + }; let config = Config::default(); let mode = determine_network_mode(&args, &profile, &config); diff --git a/src/config/mod.rs b/src/config/mod.rs index cf7e0a2..09642af 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -8,8 +8,8 @@ pub use global::load_global_config; pub use merge::merge_configs; pub(crate) use profile::merge_unique; pub use profile::{ - compose_profiles, load_profile, load_profiles, BuiltinProfile, Profile, ProfileError, - ProfileFilesystem, ProfileShell, + compose_profiles, load_profile, load_profiles, BuiltinProfile, PlatformProfile, + PlatformProfiles, Profile, ProfileError, ProfileFilesystem, ProfileShell, }; pub use project::load_project_config; pub use schema::{Config, ExecSugid, NetworkMode}; diff --git a/src/config/profile.rs b/src/config/profile.rs index 28be4e5..e65f25d 100644 --- a/src/config/profile.rs +++ b/src/config/profile.rs @@ -71,6 +71,73 @@ pub struct Profile { /// Allow execution of setuid/setgid binaries #[serde(default)] pub allow_exec_sugid: Option, + /// Per-OS additions, folded into the fields above when the profile loads. + /// + /// Lets one profile serve both platforms: `~/.cargo` is shared, while + /// `/System` and `/proc` live under `[platform.macos]` and + /// `[platform.linux]` respectively. + #[serde(default)] + pub platform: PlatformProfiles, +} + +/// Per-OS overlays declared in a profile. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct PlatformProfiles { + pub macos: Option, + pub linux: Option, +} + +/// The subset of a profile that may be overridden per OS. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct PlatformProfile { + pub network_mode: Option, + pub filesystem: ProfileFilesystem, + pub shell: ProfileShell, +} + +impl Profile { + /// Fold the overlay matching the build target into the base fields. + /// + /// Applied when a profile is loaded, so every consumer downstream sees a + /// single flat profile and never has to think about platforms. + pub fn flatten_platform(mut self) -> Self { + let overlay = if cfg!(target_os = "macos") { + self.platform.macos.take() + } else { + self.platform.linux.take() + }; + self.platform = PlatformProfiles::default(); + + let Some(overlay) = overlay else { + return self; + }; + + if overlay.network_mode.is_some() { + self.network_mode = overlay.network_mode; + } + merge_unique( + &mut self.filesystem.allow_read, + &overlay.filesystem.allow_read, + ); + merge_unique( + &mut self.filesystem.deny_read, + &overlay.filesystem.deny_read, + ); + merge_unique( + &mut self.filesystem.allow_write, + &overlay.filesystem.allow_write, + ); + merge_unique( + &mut self.filesystem.allow_list_dirs, + &overlay.filesystem.allow_list_dirs, + ); + merge_unique(&mut self.shell.pass_env, &overlay.shell.pass_env); + merge_unique(&mut self.shell.deny_env, &overlay.shell.deny_env); + + self + } } /// Profile filesystem configuration @@ -158,17 +225,19 @@ impl BuiltinProfile { Self::Bun => include_str!("../../profiles/bun.toml"), Self::Opencode => include_str!("../../profiles/opencode.toml"), }; - toml::from_str(toml_str).map_err(|e| ProfileError::InvalidBuiltin { - name: self.name(), - error: e, - }) + toml::from_str::(toml_str) + .map(Profile::flatten_platform) + .map_err(|e| ProfileError::InvalidBuiltin { + name: self.name(), + error: e, + }) } } /// Load a profile from a TOML file pub fn load_profile(path: &Path) -> Result { let content = std::fs::read_to_string(path)?; - Ok(toml::from_str(&content)?) + Ok(toml::from_str::(&content)?.flatten_platform()) } /// Load profiles by name, optionally searching in a custom directory. diff --git a/src/lib.rs b/src/lib.rs index 58d253e..32856a1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,17 @@ use anyhow::Result; use cli::args::Args; pub fn run() -> Result<()> { + // The Linux backend re-execs this binary as its sandbox launcher. Handle + // that before clap runs so the target command's arguments are passed + // through verbatim and never reinterpreted as sx flags. + #[cfg(target_os = "linux")] + { + let argv: Vec = std::env::args_os().collect(); + if argv.get(1).and_then(|a| a.to_str()) == Some(sandbox::backend::APPLY_FLAG) { + sandbox::linux::apply::run(&argv[2..]); + } + } + let args = Args::parse_args(); if args.init { diff --git a/src/sandbox/backend.rs b/src/sandbox/backend.rs new file mode 100644 index 0000000..b65dee8 --- /dev/null +++ b/src/sandbox/backend.rs @@ -0,0 +1,366 @@ +//! Platform sandbox backends. +//! +//! Both platforms follow the same shape: `sx` resolves a policy, writes it to a +//! private temp file, and launches the target command through a helper that +//! applies the policy before `exec`. +//! +//! | Platform | Helper | Policy language | Handoff | +//! |----------|--------|-----------------|---------| +//! | macOS | `/usr/bin/sandbox-exec` | Seatbelt profile | temp file (`-f`) | +//! | Linux | `sx --sandbox-apply` (this binary) | serialised `SandboxParams`, enforced with Landlock | child environment | +//! +//! Both implementations are compiled on every platform so they stay +//! type-checked everywhere; only the selection below is target-specific. + +use crate::sandbox::params::SandboxParams; +use crate::sandbox::seatbelt::generate_seatbelt_profile; +use std::ffi::OsString; +use std::fs; +use std::io; +use std::path::PathBuf; +use tempfile::NamedTempFile; + +/// Hidden argument that puts `sx` into "apply sandbox then exec" mode. +pub const APPLY_FLAG: &str = "--sandbox-apply"; + +/// Environment variable carrying the serialised policy to the helper. +/// +/// Deliberately not a file. The sandbox grants write access to `/tmp`, so a +/// policy file there can be swapped by a concurrent sandboxed process between +/// the moment `sx` writes it and the moment the helper reads it. The child's +/// environment is set by the parent at `execve` time and cannot be altered by +/// anyone else, which removes the window rather than narrowing it. +#[cfg(target_os = "linux")] +pub const SPEC_ENV: &str = "SX_SANDBOX_SPEC"; + +/// Refuse specs that would not survive `execve`, rather than failing with a +/// bare E2BIG. Linux allows 128 KiB per environment entry. +#[cfg(target_os = "linux")] +const MAX_SPEC_BYTES: usize = 96 * 1024; + +/// Error building a sandbox policy +#[derive(Debug)] +pub enum PolicyError { + Io(io::Error), + /// The backend refused to build a policy from these parameters. + Invalid(String), +} + +impl std::fmt::Display for PolicyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PolicyError::Io(e) => write!(f, "IO error: {}", e), + PolicyError::Invalid(msg) => write!(f, "{}", msg), + } + } +} + +impl std::error::Error for PolicyError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + PolicyError::Io(e) => Some(e), + PolicyError::Invalid(_) => None, + } + } +} + +impl From for PolicyError { + fn from(e: io::Error) -> Self { + PolicyError::Io(e) + } +} + +/// A prepared sandbox launcher. +/// +/// `program` + `args` are prepended to the user's command, and `launcher_env` +/// is applied to the child *after* environment filtering. Any policy temp file +/// is owned here and must outlive the child process that reads it. +#[derive(Debug)] +pub struct Launch { + pub program: PathBuf, + pub args: Vec, + pub launcher_env: Vec<(OsString, OsString)>, + _policy: Option, +} + +/// Human-readable name of the active enforcement mechanism. +pub const fn name() -> &'static str { + #[cfg(target_os = "macos")] + { + "seatbelt" + } + #[cfg(target_os = "linux")] + { + "landlock" + } +} + +/// One-line description of the active backend, for `--explain` and `--verbose`. +pub fn describe() -> String { + #[cfg(target_os = "macos")] + { + "seatbelt (/usr/bin/sandbox-exec)".to_string() + } + #[cfg(target_os = "linux")] + { + // Kernel details come from `caveats()` so they are not repeated. + "landlock".to_string() + } +} + +/// Platform-specific caveats that apply to these parameters. +pub fn caveats(params: &SandboxParams) -> Vec { + #[cfg(target_os = "macos")] + { + let _ = params; + Vec::new() + } + #[cfg(target_os = "linux")] + { + crate::sandbox::linux::policy::notes(params) + } +} + +/// Build the launcher for the current platform. +pub fn prepare(params: &SandboxParams) -> Result { + #[cfg(target_os = "macos")] + { + prepare_seatbelt(params) + } + #[cfg(target_os = "linux")] + { + prepare_landlock(params) + } +} + +/// Render the policy as text, for `--dry-run`. +pub fn render_policy(params: &SandboxParams) -> Result { + #[cfg(target_os = "macos")] + { + render_seatbelt(params) + } + #[cfg(target_os = "linux")] + { + Ok(crate::sandbox::linux::policy::render(params)) + } +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +compile_error!("sx supports macOS (Seatbelt) and Linux (Landlock) only"); + +// --- macOS (Seatbelt) --- +// +// Compiled on every target so the macOS launcher keeps being type-checked (and +// unit-tested) by Linux CI; only the selection above is target-specific. + +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn render_seatbelt(params: &SandboxParams) -> Result { + generate_seatbelt_profile(params).map_err(|e| PolicyError::Invalid(e.to_string())) +} + +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn prepare_seatbelt(params: &SandboxParams) -> Result { + let profile = render_seatbelt(params)?; + let policy = NamedTempFile::new()?; + fs::write(policy.path(), &profile)?; + + Ok(Launch { + program: PathBuf::from("/usr/bin/sandbox-exec"), + args: vec![OsString::from("-f"), policy.path().into()], + launcher_env: Vec::new(), + _policy: Some(policy), + }) +} + +// --- Linux (Landlock) --- + +/// Serialise the resolved parameters for the helper process. +#[cfg(target_os = "linux")] +fn prepare_landlock(params: &SandboxParams) -> Result { + // Strip everything the helper does not need. Environment filtering is + // applied by the parent to the child's `Command`, so carrying `set_env` + // values across would expose configured secrets in the helper's + // /proc//environ for no benefit. Raw seatbelt rules are macOS-only. + let mut spec = params.clone(); + spec.pass_env.clear(); + spec.deny_env.clear(); + spec.set_env.clear(); + spec.raw_rules = None; + + let spec = toml::to_string(&spec) + .map_err(|e| PolicyError::Invalid(format!("failed to serialise sandbox spec: {}", e)))?; + + if spec.len() > MAX_SPEC_BYTES { + return Err(PolicyError::Invalid(format!( + "sandbox policy is too large to hand to the launcher ({} bytes, limit {}). \ + Reduce the number of allowed paths.", + spec.len(), + MAX_SPEC_BYTES + ))); + } + + // /proc/self/exe rather than current_exe(): it always resolves to the + // running image, even if the binary was replaced or unlinked underneath us, + // and it cannot be swapped between resolving the path and exec'ing it. + Ok(Launch { + program: PathBuf::from("/proc/self/exe"), + args: vec![OsString::from(APPLY_FLAG)], + launcher_env: vec![(OsString::from(SPEC_ENV), OsString::from(spec))], + _policy: None, + }) +} + +/// Parse a serialised spec produced by [`prepare`]. +#[cfg(target_os = "linux")] +pub fn parse_spec(spec: &str) -> Result { + toml::from_str(spec).map_err(|e| PolicyError::Invalid(format!("invalid sandbox spec: {}", e))) +} + +#[cfg(test)] +mod tests { + use super::*; + #[cfg(target_os = "linux")] + use std::ffi::OsStr; + use std::path::Path; + + fn sample() -> SandboxParams { + SandboxParams { + working_dir: PathBuf::from("/tmp/project"), + allow_read: vec![PathBuf::from("/usr")], + ..Default::default() + } + } + + #[test] + fn seatbelt_launcher_wraps_sandbox_exec() { + let launch = prepare_seatbelt(&sample()).unwrap(); + assert_eq!(launch.program, Path::new("/usr/bin/sandbox-exec")); + assert_eq!(launch.args[0], OsString::from("-f")); + + let profile = fs::read_to_string(&launch.args[1]).unwrap(); + assert!(profile.starts_with("(version 1)")); + assert!(profile.contains("(deny default)")); + } + + #[test] + fn seatbelt_render_rejects_paths_that_would_break_the_profile() { + let params = SandboxParams { + allow_read: vec![PathBuf::from("/tmp/eviln\"(allow default)")], + ..Default::default() + }; + assert!(matches!( + render_seatbelt(¶ms), + Err(PolicyError::Invalid(_)) + )); + } + + #[test] + fn backend_name_matches_target() { + if cfg!(target_os = "macos") { + assert_eq!(name(), "seatbelt"); + } else { + assert_eq!(name(), "landlock"); + } + } + + #[cfg(target_os = "linux")] + fn spec_of(launch: &Launch) -> SandboxParams { + let (_, value) = launch + .launcher_env + .iter() + .find(|(k, _)| k == OsStr::new(SPEC_ENV)) + .expect("launcher carries the spec"); + parse_spec(value.to_str().unwrap()).unwrap() + } + + /// The policy never touches the filesystem: a file under /tmp could be + /// swapped by a concurrent sandboxed process before the helper reads it. + #[cfg(target_os = "linux")] + #[test] + fn landlock_launcher_passes_the_spec_through_the_environment() { + let launch = prepare_landlock(&sample()).unwrap(); + assert_eq!(launch.args, vec![OsString::from(APPLY_FLAG)]); + assert!( + launch + .args + .iter() + .all(|a| !a.to_str().unwrap().contains("/tmp")), + "spec path leaked into argv" + ); + + let spec = spec_of(&launch); + assert_eq!(spec.working_dir, PathBuf::from("/tmp/project")); + assert_eq!(spec.allow_read, vec![PathBuf::from("/usr")]); + } + + #[cfg(target_os = "linux")] + #[test] + fn oversized_specs_are_rejected_before_exec() { + let params = SandboxParams { + allow_read: (0..20_000) + .map(|i| PathBuf::from(format!("/some/reasonably/long/path/number/{i}"))) + .collect(), + ..sample() + }; + assert!(matches!( + prepare_landlock(¶ms), + Err(PolicyError::Invalid(_)) + )); + } + + /// The helper needs the policy, not the environment: env filtering happens + /// in the parent, so nothing sensitive is written to the spec file. + #[cfg(target_os = "linux")] + #[test] + fn spec_omits_environment_values() { + let params = SandboxParams { + set_env: [("TOKEN".to_string(), "s3cret".to_string())] + .into_iter() + .collect(), + pass_env: vec!["TOKEN".into()], + deny_env: vec!["AWS_*".into()], + raw_rules: Some("(allow x)".into()), + ..sample() + }; + let launch = prepare_landlock(¶ms).unwrap(); + + let raw = format!("{:?}", launch.launcher_env); + assert!(!raw.contains("s3cret"), "spec leaked a set_env value"); + + let back = spec_of(&launch); + assert!(back.set_env.is_empty()); + assert!(back.pass_env.is_empty()); + assert!(back.deny_env.is_empty()); + assert!(back.raw_rules.is_none()); + } + + #[cfg(target_os = "linux")] + #[test] + fn spec_round_trips_the_policy_fields() { + let params = SandboxParams { + working_dir: PathBuf::from("/w"), + home_dir: PathBuf::from("/h"), + network_mode: crate::config::schema::NetworkMode::Localhost, + allow_read: vec![PathBuf::from("/a")], + deny_read: vec![PathBuf::from("/d")], + allow_write: vec![PathBuf::from("/w2")], + allow_list_dirs: vec![PathBuf::from("/l")], + raw_rules: None, + allow_exec_sugid: crate::config::schema::ExecSugid::Paths(vec!["/bin/ps".into()]), + pass_env: Vec::new(), + deny_env: Vec::new(), + set_env: Default::default(), + }; + let launch = prepare_landlock(¶ms).unwrap(); + let back = spec_of(&launch); + + assert_eq!(back.working_dir, params.working_dir); + assert_eq!(back.home_dir, params.home_dir); + assert_eq!(back.network_mode, params.network_mode); + assert_eq!(back.allow_read, params.allow_read); + assert_eq!(back.deny_read, params.deny_read); + assert_eq!(back.allow_write, params.allow_write); + assert_eq!(back.allow_list_dirs, params.allow_list_dirs); + assert_eq!(back.allow_exec_sugid, params.allow_exec_sugid); + } +} diff --git a/src/sandbox/executor.rs b/src/sandbox/executor.rs index d145d77..573e5dc 100644 --- a/src/sandbox/executor.rs +++ b/src/sandbox/executor.rs @@ -1,5 +1,10 @@ -// sandbox-exec invocation -use crate::sandbox::seatbelt::{generate_seatbelt_profile, SandboxParams, SeatbeltError}; +//! Sandbox launcher invocation. +//! +//! Platform-neutral: process supervision (signal forwarding, process groups, +//! terminal foreground handling) and environment filtering live here, while the +//! actual confinement mechanism comes from [`crate::sandbox::backend`]. +use crate::sandbox::backend::{self, PolicyError}; +use crate::sandbox::params::SandboxParams; use crate::sandbox::trace::TraceSession; use signal_hook::consts::{SIGHUP, SIGINT, SIGTERM}; use signal_hook::iterator::Signals; @@ -9,7 +14,7 @@ use std::os::unix::process::CommandExt; use std::path::Path; use std::process::{Command, ExitStatus, Stdio}; use std::time::Duration; -use tempfile::{NamedTempFile, TempDir}; +use tempfile::TempDir; /// Grace period between SIGTERM and SIGKILL when forwarding shutdown signals /// to the sandboxed process group. Long enough for typical cleanup (closing @@ -33,15 +38,15 @@ pub mod exit_codes { pub enum ExecutionError { /// IO error during execution Io(io::Error), - /// Seatbelt profile generation error - Seatbelt(SeatbeltError), + /// Sandbox policy could not be built + Policy(PolicyError), } impl std::fmt::Display for ExecutionError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ExecutionError::Io(e) => write!(f, "IO error: {}", e), - ExecutionError::Seatbelt(e) => write!(f, "Seatbelt error: {}", e), + ExecutionError::Policy(e) => write!(f, "Sandbox policy error: {}", e), } } } @@ -50,7 +55,7 @@ impl std::error::Error for ExecutionError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { ExecutionError::Io(e) => Some(e), - ExecutionError::Seatbelt(e) => Some(e), + ExecutionError::Policy(e) => Some(e), } } } @@ -61,9 +66,9 @@ impl From for ExecutionError { } } -impl From for ExecutionError { - fn from(e: SeatbeltError) -> Self { - ExecutionError::Seatbelt(e) +impl From for ExecutionError { + fn from(e: PolicyError) -> Self { + ExecutionError::Policy(e) } } @@ -82,6 +87,43 @@ pub fn execute_sandboxed( execute_sandboxed_with_trace(params, command, shell, false, None) } +/// Start a violation trace, if this platform can observe them. +/// +/// macOS exposes sandbox denials to unprivileged users through the unified log. +/// Linux has no equivalent: Landlock denials are only recorded through the +/// kernel audit subsystem (Linux 6.15+), which needs `CAP_AUDIT_READ` to read, +/// so `--trace` reports the limitation instead of silently doing nothing. +#[cfg(target_os = "macos")] +fn start_trace(trace: bool, trace_file: Option<&Path>) -> Option { + if !trace && trace_file.is_none() { + return None; + } + if let Some(path) = trace_file { + eprintln!( + "\x1b[90m[sx:trace]\x1b[0m Writing sandbox violations to {}", + path.display() + ); + std::thread::sleep(std::time::Duration::from_millis(100)); + TraceSession::start_to_file(path).ok() + } else { + eprintln!("\x1b[90m[sx:trace]\x1b[0m Starting sandbox violation trace..."); + std::thread::sleep(std::time::Duration::from_millis(100)); + TraceSession::start().ok() + } +} + +#[cfg(not(target_os = "macos"))] +fn start_trace(trace: bool, trace_file: Option<&Path>) -> Option { + if trace || trace_file.is_some() { + eprintln!( + "\x1b[33m[sx:trace]\x1b[0m Violation tracing is unavailable on Linux: Landlock \ + denials are only visible through the kernel audit log, which requires privileges. \ + Use `sx --dry-run` to see the exact policy, or `sx --explain` for the resolved paths." + ); + } + None +} + /// Execute a command inside a sandbox with optional tracing pub fn execute_sandboxed_with_trace( params: &SandboxParams, @@ -90,38 +132,22 @@ pub fn execute_sandboxed_with_trace( trace: bool, trace_file: Option<&Path>, ) -> Result { - // Start trace session if requested - let mut trace_session = if trace || trace_file.is_some() { - if let Some(path) = trace_file { - eprintln!( - "\x1b[90m[sx:trace]\x1b[0m Writing sandbox violations to {}", - path.display() - ); - std::thread::sleep(std::time::Duration::from_millis(100)); - TraceSession::start_to_file(path).ok() - } else { - eprintln!("\x1b[90m[sx:trace]\x1b[0m Starting sandbox violation trace..."); - std::thread::sleep(std::time::Duration::from_millis(100)); - TraceSession::start().ok() - } - } else { - None - }; + let mut trace_session = start_trace(trace, trace_file); - // Generate the seatbelt profile - let profile_content = generate_seatbelt_profile(params)?; - - // Write profile to temp file - let profile_file = NamedTempFile::new()?; - fs::write(profile_file.path(), &profile_content)?; - - // Build sandbox-exec command - let mut cmd = Command::new("/usr/bin/sandbox-exec"); - cmd.arg("-f").arg(profile_file.path()); + // Build the platform sandbox launcher. `launch` owns any policy file and + // must outlive the child that reads it. + let launch = backend::prepare(params)?; + let mut cmd = Command::new(&launch.program); + cmd.args(&launch.args); // Apply environment filtering (clears env, then selectively passes through) apply_env_filter(&mut cmd, params); + // Launcher variables go on after filtering so they are not stripped. + for (key, value) in &launch.launcher_env { + cmd.env(key, value); + } + // Set SANDBOX_MODE environment variable for shell prompt integration let mode_str = match params.network_mode { crate::config::schema::NetworkMode::Offline => "offline", @@ -136,7 +162,7 @@ pub fn execute_sandboxed_with_trace( let shell_path = shell .map(String::from) .or_else(|| std::env::var("SHELL").ok()) - .unwrap_or_else(|| "/bin/zsh".to_string()); + .unwrap_or_else(|| crate::shell::default_shell().to_string()); cmd.arg(&shell_path); } else { // Execute the provided command @@ -204,19 +230,15 @@ pub fn execute_sandboxed_captured( params: &SandboxParams, command: &[String], ) -> Result<(ExitStatus, Vec, Vec), ExecutionError> { - // Generate the seatbelt profile - let profile_content = generate_seatbelt_profile(params)?; - - // Write profile to temp file - let profile_file = NamedTempFile::new()?; - fs::write(profile_file.path(), &profile_content)?; - - // Build sandbox-exec command - let mut cmd = Command::new("/usr/bin/sandbox-exec"); - cmd.arg("-f").arg(profile_file.path()); + let launch = backend::prepare(params)?; + let mut cmd = Command::new(&launch.program); + cmd.args(&launch.args); // Apply environment filtering apply_env_filter(&mut cmd, params); + for (key, value) in &launch.launcher_env { + cmd.env(key, value); + } cmd.args(command); @@ -225,9 +247,9 @@ pub fn execute_sandboxed_captured( Ok((output.status, output.stdout, output.stderr)) } -/// Print the generated seatbelt profile (dry-run mode) -pub fn dry_run(params: &SandboxParams) -> Result { - generate_seatbelt_profile(params) +/// Render the sandbox policy without executing anything (dry-run mode) +pub fn dry_run(params: &SandboxParams) -> Result { + backend::render_policy(params) } /// RAII guard that SIGKILLs an entire process group on drop. @@ -389,17 +411,29 @@ fn matches_env_pattern(name: &str, patterns: &[String]) -> bool { false } +/// Variables the dynamic loader uses to inject code into a process. +/// +/// These are dropped unconditionally - they are never forwarded and cannot be +/// re-added through `set_env`. The sandbox launcher is itself a process, so a +/// preloaded library would run inside it *before* the policy is applied and +/// could stop the sandbox from being enforced at all. `pass_env` normally +/// filters them out as an allow-list, but it is empty when a project sets +/// `inherit_base = false`, which is exactly when this matters. +fn is_loader_injection_var(key: &str) -> bool { + // DYLD_* is the macOS loader, LD_* the glibc/musl one. Both lists are + // applied everywhere so behaviour does not depend on the build target. + key.starts_with("DYLD_") || key.starts_with("LD_") +} + /// Apply environment filtering to a Command. /// Clears all env, then selectively passes through allowed vars. fn apply_env_filter(cmd: &mut Command, params: &SandboxParams) { - const DANGEROUS_PREFIXES: &[&str] = &["DYLD_"]; - cmd.env_clear(); let parent_env: std::collections::HashMap = std::env::vars().collect(); for (key, value) in &parent_env { - if DANGEROUS_PREFIXES.iter().any(|p| key.starts_with(p)) { + if is_loader_injection_var(key) { continue; } if matches_env_pattern(key, ¶ms.deny_env) { @@ -411,7 +445,7 @@ fn apply_env_filter(cmd: &mut Command, params: &SandboxParams) { } for (key, value) in ¶ms.set_env { - if DANGEROUS_PREFIXES.iter().any(|p| key.starts_with(p)) { + if is_loader_injection_var(key) { continue; } if matches_env_pattern(key, ¶ms.deny_env) { @@ -428,19 +462,31 @@ mod tests { use std::path::PathBuf; #[test] - fn test_dry_run_returns_profile() { + fn test_dry_run_returns_policy() { let params = SandboxParams { working_dir: PathBuf::from("/tmp/test"), - home_dir: PathBuf::from("/Users/test"), + home_dir: PathBuf::from("/tmp/home"), network_mode: NetworkMode::Offline, ..Default::default() }; - let profile = dry_run(¶ms).unwrap(); - assert!(profile.contains("(version 1)")); - assert!(profile.contains("(deny default)")); + let policy = dry_run(¶ms).unwrap(); + + #[cfg(target_os = "macos")] + { + assert!(policy.contains("(version 1)")); + assert!(policy.contains("(deny default)")); + } + #[cfg(target_os = "linux")] + { + assert!(policy.contains("landlock")); + assert!(policy.contains("/tmp/test")); + } } + /// A Seatbelt profile is text, so an unescaped quote in a path could inject + /// rules and must be rejected before it reaches `sandbox-exec`. + #[cfg(target_os = "macos")] #[test] fn test_dry_run_fails_on_invalid_path() { let params = SandboxParams { @@ -452,6 +498,34 @@ mod tests { assert!(result.is_err()); } + /// Landlock takes paths as opaque bytes through open(2) - there is no + /// policy text to escape from, so odd characters are simply path bytes. + #[cfg(target_os = "linux")] + #[test] + fn test_dry_run_treats_quotes_as_ordinary_path_bytes() { + let params = SandboxParams { + working_dir: PathBuf::from("/tmp/test\"injection"), + ..Default::default() + }; + + let policy = dry_run(¶ms).unwrap(); + assert!(policy.contains("/tmp/test\"injection")); + } + + #[test] + fn loader_injection_vars_are_always_dropped() { + assert!(is_loader_injection_var("LD_PRELOAD")); + assert!(is_loader_injection_var("LD_LIBRARY_PATH")); + assert!(is_loader_injection_var("LD_AUDIT")); + assert!(is_loader_injection_var("DYLD_INSERT_LIBRARIES")); + assert!(is_loader_injection_var("DYLD_LIBRARY_PATH")); + + // Ordinary variables that merely start with the same letters stay. + assert!(!is_loader_injection_var("LDFLAGS")); + assert!(!is_loader_injection_var("PATH")); + assert!(!is_loader_injection_var("HOME")); + } + #[test] fn test_matches_env_pattern_exact() { assert!(matches_env_pattern("HOME", &["HOME".to_string()])); diff --git a/src/sandbox/linux/apply.rs b/src/sandbox/linux/apply.rs new file mode 100644 index 0000000..cb34f91 --- /dev/null +++ b/src/sandbox/linux/apply.rs @@ -0,0 +1,70 @@ +//! The `sx --sandbox-apply` helper. +//! +//! `sx` re-execs itself in this mode as the sandbox launcher, mirroring how the +//! macOS backend goes through `/usr/bin/sandbox-exec`. Doing the work in a +//! freshly exec'd process rather than in a `pre_exec` hook keeps it +//! single-threaded (required by `unshare(CLONE_NEWUSER)`) and avoids running +//! allocating code between `fork` and `exec`. +//! +//! The helper can only ever *add* restrictions - Landlock rulesets compose +//! monotonically and namespaces only remove access - so it is safe for a +//! sandboxed process to invoke it again. + +use crate::sandbox::backend::{self, SPEC_ENV}; +use crate::sandbox::executor::exit_codes; +use crate::sandbox::linux::{landlock, net, rules}; +use std::ffi::OsString; +use std::os::unix::process::CommandExt; +use std::process::Command; + +/// Apply the sandbox described by `$SX_SANDBOX_SPEC`, then exec `command`. +/// +/// Never returns on success: the process is replaced by the target command. +pub fn run(command: &[OsString]) -> ! { + if command.is_empty() { + fail("usage: sx --sandbox-apply [args...]"); + } + + let Ok(spec) = std::env::var(SPEC_ENV) else { + fail(&format!( + "{SPEC_ENV} is not set; --sandbox-apply is internal to sx and is not meant to be \ + invoked directly" + )); + }; + // Drop it before exec so the sandboxed program never inherits the policy. + std::env::remove_var(SPEC_ENV); + + let params = match backend::parse_spec(&spec) { + Ok(params) => params, + Err(e) => fail(&format!("could not read sandbox spec: {e}")), + }; + + // Network first: writing /proc/self/uid_map must happen before the + // filesystem policy is in force. + if let Err(e) = net::apply(params.network_mode) { + fail(&e); + } + + if let Err(e) = landlock::apply(&rules::build(¶ms)) { + fail(&e); + } + + let error = Command::new(&command[0]).args(&command[1..]).exec(); + + let code = match error.kind() { + std::io::ErrorKind::NotFound => exit_codes::COMMAND_NOT_FOUND, + std::io::ErrorKind::PermissionDenied => exit_codes::COMMAND_NOT_EXECUTABLE, + _ => exit_codes::GENERAL_ERROR, + }; + eprintln!( + "\x1b[31m[sx]\x1b[0m {}: {}", + command[0].to_string_lossy(), + error + ); + std::process::exit(code); +} + +fn fail(message: &str) -> ! { + eprintln!("\x1b[31m[sx]\x1b[0m {}", message); + std::process::exit(exit_codes::CONFIG_ERROR); +} diff --git a/src/sandbox/linux/landlock.rs b/src/sandbox/linux/landlock.rs new file mode 100644 index 0000000..3b2cabd --- /dev/null +++ b/src/sandbox/linux/landlock.rs @@ -0,0 +1,183 @@ +//! Landlock enforcement. +//! +//! Landlock is the closest Linux analogue to Seatbelt: an unprivileged, +//! per-process filesystem policy that survives `exec` and is inherited by every +//! descendant. Unlike a mount namespace it does not change the filesystem +//! *view* - denied paths still exist, they just return `EACCES`, which matches +//! Seatbelt's behaviour and keeps error messages recognisable. + +use crate::sandbox::linux::rules::{Rule, LIST, READ, WRITE}; +use landlock::{ + Access, AccessFs, BitFlags, CompatLevel, Compatible, PathBeneath, PathFd, Ruleset, RulesetAttr, + RulesetCreatedAttr, RulesetStatus, Scope, ABI, +}; + +/// Access rights `sx` asks the kernel to enforce. +/// +/// ABI v3 is the v1 file rights plus `Refer` (cross-directory rename/link, which +/// build tools need) and `Truncate`. `IoctlDev` (v5) is deliberately left +/// *unhandled* so device ioctls stay implicitly allowed - this mirrors the +/// Seatbelt profile's global `(allow file-ioctl)` and keeps terminal control +/// (`tcsetattr`, window resize) working inside an interactive shell. +const HANDLED: ABI = ABI::V3; + +/// What the running kernel can actually enforce. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Support { + /// Landlock ABI level, or 0 when unsupported. + pub abi: i32, +} + +impl Support { + /// Ask the kernel for its Landlock ABI level. + /// + /// `landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION)` is + /// the documented probe; it returns the ABI version or `-1` with `ENOSYS` / + /// `EOPNOTSUPP` when Landlock is missing or disabled. + pub fn detect() -> Self { + const LANDLOCK_CREATE_RULESET_VERSION: u32 = 1; + let rc = unsafe { + libc::syscall( + libc::SYS_landlock_create_ruleset, + std::ptr::null::(), + 0_usize, + LANDLOCK_CREATE_RULESET_VERSION, + ) + }; + Self { + abi: if rc < 0 { 0 } else { rc as i32 }, + } + } + + pub fn available(&self) -> bool { + self.abi > 0 + } + + /// Rights the kernel understands but is too old to enforce. + pub fn missing(&self) -> Vec<&'static str> { + let mut gaps = Vec::new(); + if self.abi > 0 && self.abi < 2 { + gaps.push("cross-directory rename/link control (ABI 2)"); + } + if self.abi > 0 && self.abi < 3 { + gaps.push("truncate control (ABI 3)"); + } + if self.abi > 0 && self.abi < 6 { + gaps.push("signal scoping (ABI 6)"); + } + gaps + } +} + +fn flags_for(access: u8) -> BitFlags { + let mut flags = BitFlags::::EMPTY; + if access & READ != 0 { + flags |= AccessFs::Execute | AccessFs::ReadFile; + } + if access & LIST != 0 { + flags |= AccessFs::ReadDir; + } + if access & WRITE != 0 { + flags |= AccessFs::from_write(HANDLED); + } + flags +} + +/// Apply `rules` to the calling process and every process it later execs. +/// +/// Paths that do not exist are skipped: Landlock rules reference open file +/// descriptors, while profiles legitimately list optional paths such as +/// `~/.bashrc`. Seatbelt tolerates the same thing. +pub fn apply(rules: &[Rule]) -> Result { + let mut ruleset = Ruleset::default() + .set_compatibility(CompatLevel::BestEffort) + .handle_access(AccessFs::from_all(HANDLED)) + .map_err(|e| format!("failed to select Landlock access rights: {e}"))? + // Matches the Seatbelt profile's `(allow signal (target self))`: the + // sandbox may signal its own processes, nothing outside it. + .scope(Scope::Signal) + .map_err(|e| format!("failed to scope signals: {e}"))? + .create() + .map_err(|e| format!("failed to create Landlock ruleset: {e}"))?; + + for rule in rules { + let access = flags_for(rule.access); + if access.is_empty() { + continue; + } + let Ok(fd) = PathFd::new(&rule.path) else { + continue; // optional path that is not present on this machine + }; + ruleset = ruleset + .add_rule(PathBeneath::new(fd, access)) + .map_err(|e| format!("failed to add rule for {}: {e}", rule.path.display()))?; + } + + let status = ruleset + .restrict_self() + .map_err(|e| format!("failed to enforce Landlock ruleset: {e}"))?; + + // Fail closed. A sandbox that silently does not sandbox is worse than none. + if status.ruleset == RulesetStatus::NotEnforced { + return Err( + "Landlock is not enforced by this kernel. sx needs Linux 5.13+ built with \ + CONFIG_SECURITY_LANDLOCK=y and `landlock` present in /sys/kernel/security/lsm. \ + Refusing to run the command unsandboxed." + .to_string(), + ); + } + if !status.no_new_privs { + return Err("failed to set no_new_privs; refusing to run unsandboxed".to_string()); + } + + Ok(status.ruleset) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn read_maps_to_execute_and_read_file() { + let flags = flags_for(READ); + assert!(flags.contains(AccessFs::ReadFile)); + assert!(flags.contains(AccessFs::Execute)); + assert!(!flags.contains(AccessFs::ReadDir)); + assert!(!flags.contains(AccessFs::WriteFile)); + } + + #[test] + fn list_maps_to_read_dir_only() { + let flags = flags_for(LIST); + assert_eq!(flags, AccessFs::ReadDir); + } + + #[test] + fn write_includes_refer_and_truncate() { + let flags = flags_for(WRITE); + assert!(flags.contains(AccessFs::WriteFile)); + assert!(flags.contains(AccessFs::Refer)); + assert!(flags.contains(AccessFs::Truncate)); + assert!(!flags.contains(AccessFs::ReadFile)); + } + + #[test] + fn ioctl_dev_is_left_unhandled_so_terminals_keep_working() { + assert!(!AccessFs::from_all(HANDLED).contains(AccessFs::IoctlDev)); + } + + #[test] + fn empty_access_produces_no_flags() { + assert!(flags_for(0).is_empty()); + } + + #[test] + fn support_detection_matches_running_kernel() { + let support = Support::detect(); + assert!(support.abi >= 0); + assert_eq!(support.available(), support.abi > 0); + if support.abi >= 6 { + assert!(support.missing().is_empty()); + } + } +} diff --git a/src/sandbox/linux/mod.rs b/src/sandbox/linux/mod.rs new file mode 100644 index 0000000..7f907fe --- /dev/null +++ b/src/sandbox/linux/mod.rs @@ -0,0 +1,8 @@ +//! Linux sandbox backend: Landlock for the filesystem, namespaces (or seccomp) +//! for the network. + +pub mod apply; +pub mod landlock; +pub mod net; +pub mod policy; +pub mod rules; diff --git a/src/sandbox/linux/net.rs b/src/sandbox/linux/net.rs new file mode 100644 index 0000000..26e543b --- /dev/null +++ b/src/sandbox/linux/net.rs @@ -0,0 +1,420 @@ +//! Network isolation for the Linux backend. +//! +//! Landlock only gained TCP controls in ABI 4 and filters by *port*, never by +//! address, so it cannot express `sx`'s network modes on its own. Network +//! isolation is done with namespaces instead, with a seccomp filter as a +//! fallback for distributions that block unprivileged user namespaces +//! (Ubuntu's `kernel.apparmor_restrict_unprivileged_userns`, for example). +//! +//! | Mode | Mechanism | +//! |------|-----------| +//! | `online` | nothing | +//! | `offline` | empty network namespace, else seccomp on `socket(AF_INET*)` | +//! | `localhost` | network namespace with `lo` brought up | +//! +//! `localhost` differs from macOS by design: Seatbelt filters the *host's* +//! loopback, so a sandboxed server is reachable from the host. A network +//! namespace gives the sandbox its own private loopback instead - sandboxed +//! processes reach each other over 127.0.0.1, and nothing else. That is +//! strictly tighter (host-local databases, agent sockets and daemons stay out +//! of reach) but it is a real behavioural difference. + +use crate::config::schema::NetworkMode; +use std::io; + +/// How network access ended up being restricted. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Enforcement { + /// `online`: no restriction applied. + Unrestricted, + /// Private network namespace, optionally with loopback up. + Namespace { loopback: bool }, + /// seccomp filter rejecting AF_INET/AF_INET6/AF_PACKET sockets. + Seccomp, +} + +impl std::fmt::Display for Enforcement { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Enforcement::Unrestricted => write!(f, "unrestricted"), + Enforcement::Namespace { loopback: false } => { + write!(f, "network namespace (no interfaces)") + } + Enforcement::Namespace { loopback: true } => { + write!(f, "network namespace (private loopback only)") + } + Enforcement::Seccomp => write!(f, "seccomp (AF_INET/AF_INET6/AF_PACKET blocked)"), + } + } +} + +/// Why a network namespace could not be used. +#[derive(Debug)] +enum NamespaceError { + /// `unshare` itself was refused. The process is untouched, so falling back + /// to another mechanism is safe. + Unavailable(String), + /// The namespace was created but could not be configured. The process is + /// now in a half-built namespace with unmapped credentials, so there is + /// nothing safe to fall back to. + Broken(String), +} + +impl std::fmt::Display for NamespaceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + NamespaceError::Unavailable(e) | NamespaceError::Broken(e) => write!(f, "{e}"), + } + } +} + +const ENABLE_USERNS_HINT: &str = concat!( + "Enable unprivileged user namespaces ", + "(`sysctl -w kernel.unprivileged_userns_clone=1`, ", + "or on Ubuntu `sysctl -w kernel.apparmor_restrict_unprivileged_userns=0`)" +); + +/// Restrict the calling process's network access for `mode`. +/// +/// Must run in a single-threaded process: `unshare(CLONE_NEWUSER)` requires it. +pub fn apply(mode: NetworkMode) -> Result { + match mode { + NetworkMode::Online => Ok(Enforcement::Unrestricted), + NetworkMode::Offline => { + if namespace_usable(false) { + return unshare_network(false) + .map(|()| Enforcement::Namespace { loopback: false }) + .map_err(broken_namespace); + } + match block_inet_sockets() { + Ok(()) => Ok(Enforcement::Seccomp), + // Fail closed: never run an "offline" command with live network. + Err(seccomp_err) => Err(format!( + "could not isolate the network: no usable network namespace and the \ + seccomp fallback failed ({seccomp_err}). Refusing to run with network access." + )), + } + } + NetworkMode::Localhost => { + if !namespace_usable(true) { + return Err(format!( + "localhost mode needs a usable unprivileged user namespace, which this \ + system does not provide. {ENABLE_USERNS_HINT}, or use --offline / --online \ + instead." + )); + } + unshare_network(true) + .map(|()| Enforcement::Namespace { loopback: true }) + .map_err(broken_namespace) + } + } +} + +fn broken_namespace(e: NamespaceError) -> String { + format!( + "network namespace could not be set up ({e}). Refusing to continue in a partially \ + initialised namespace." + ) +} + +/// Whether this system can give us a *fully configured* private network +/// namespace, rehearsed in a throwaway child. +/// +/// `unshare` cannot be undone, and creating the namespace is not the same as +/// being allowed to configure it: some systems let the namespace be created and +/// then refuse the credential mapping, which would strand the real process in a +/// namespace where its own files are inaccessible. Committing only after the +/// whole sequence has been shown to work keeps that state unreachable. +/// +/// The caller must be single-threaded - both the CLI and the freshly exec'd +/// launcher are - so the child may safely run ordinary Rust code before +/// `_exit`. +pub fn namespace_usable(loopback: bool) -> bool { + match unsafe { libc::fork() } { + -1 => false, + 0 => { + let ok = unshare_network(loopback).is_ok(); + unsafe { libc::_exit(i32::from(!ok)) } + } + pid => { + let mut status = 0; + if unsafe { libc::waitpid(pid, &mut status, 0) } < 0 { + return false; + } + libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0 + } + } +} + +/// Enter a private user + network namespace. +fn unshare_network(loopback: bool) -> Result<(), NamespaceError> { + let uid = unsafe { libc::geteuid() }; + let gid = unsafe { libc::getegid() }; + + if unsafe { libc::unshare(libc::CLONE_NEWUSER | libc::CLONE_NEWNET) } != 0 { + return Err(NamespaceError::Unavailable( + io::Error::last_os_error().to_string(), + )); + } + + // Past this point the namespace exists and cannot be left, so every failure + // is Broken rather than Unavailable. + // + // Map our own credentials 1:1 so files keep their normal ownership. The + // kernel allows an unprivileged single-entry map for the caller's own uid; + // `setgroups` must be denied before gid_map may be written. + write_proc("/proc/self/setgroups", "deny")?; + write_proc("/proc/self/uid_map", &format!("{uid} {uid} 1"))?; + write_proc("/proc/self/gid_map", &format!("{gid} {gid} 1"))?; + + if loopback { + bring_loopback_up()?; + } + Ok(()) +} + +fn write_proc(path: &str, value: &str) -> Result<(), NamespaceError> { + std::fs::write(path, value) + .map_err(|e| NamespaceError::Broken(format!("failed to write {path}: {e}"))) +} + +// SIOCGIFFLAGS / SIOCSIFFLAGS are stable Linux ioctl numbers. +const SIOCGIFFLAGS: libc::c_ulong = 0x8913; +const SIOCSIFFLAGS: libc::c_ulong = 0x8914; + +/// `struct ifreq`, spelled out so it does not depend on libc's union layout. +/// +/// `ifr_name` is 16 bytes on every Linux ABI and the union follows it, so the +/// flags always sit at offset 16. The tail is padded to the 64-bit union size; +/// being at least as large as the kernel's struct is what matters, since the +/// kernel copies a fixed number of bytes in. +#[repr(C)] +struct IfReq { + name: [libc::c_char; 16], + flags: libc::c_short, + _union_pad: [u8; 22], +} + +const _: () = assert!(std::mem::size_of::() >= 40); + +/// Bring `lo` up inside the new namespace. +/// +/// We hold CAP_NET_ADMIN over this namespace because we created the user +/// namespace that owns it. +fn bring_loopback_up() -> Result<(), NamespaceError> { + let fd = unsafe { libc::socket(libc::AF_INET, libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, 0) }; + if fd < 0 { + return Err(NamespaceError::Broken(format!( + "failed to open control socket: {}", + io::Error::last_os_error() + ))); + } + + let mut req = IfReq { + name: [0; 16], + flags: 0, + _union_pad: [0; 22], + }; + for (slot, byte) in req.name.iter_mut().zip(b"lo") { + *slot = *byte as libc::c_char; + } + + let result = unsafe { + if libc::ioctl(fd, SIOCGIFFLAGS, &mut req as *mut IfReq) != 0 { + Err(io::Error::last_os_error()) + } else { + req.flags |= libc::IFF_UP as libc::c_short; + if libc::ioctl(fd, SIOCSIFFLAGS, &req as *const IfReq) != 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } + } + }; + unsafe { libc::close(fd) }; + result.map_err(|e| NamespaceError::Broken(format!("failed to bring up loopback: {e}"))) +} + +// --- seccomp fallback --- + +const SECCOMP_RET_KILL_PROCESS: u32 = 0x8000_0000; +const SECCOMP_RET_ERRNO: u32 = 0x0005_0000; +const SECCOMP_RET_ALLOW: u32 = 0x7fff_0000; +const SECCOMP_SET_MODE_FILTER: libc::c_ulong = 1; + +const BPF_LD_W_ABS: u16 = 0x20; // BPF_LD | BPF_W | BPF_ABS +const BPF_JMP_JEQ_K: u16 = 0x15; // BPF_JMP | BPF_JEQ | BPF_K +const BPF_RET_K: u16 = 0x06; // BPF_RET | BPF_K + +// Offsets into `struct seccomp_data`. +const OFF_NR: u32 = 0; +const OFF_ARCH: u32 = 4; +const OFF_ARG0: u32 = 16; + +#[cfg(target_arch = "x86_64")] +const AUDIT_ARCH: u32 = 0xC000_003E; +#[cfg(target_arch = "aarch64")] +const AUDIT_ARCH: u32 = 0xC000_00B7; + +const fn insn(code: u16, jt: u8, jf: u8, k: u32) -> libc::sock_filter { + libc::sock_filter { code, jt, jf, k } +} + +/// Reject creation of IP and packet sockets for this process and its children. +/// +/// Coarser than a network namespace - it cannot distinguish loopback - but it +/// needs no namespace support, which is what makes it a usable fallback. +/// +/// `io_uring_setup` is refused as well: io_uring can open and connect sockets +/// through submission queue entries, which never pass through `socket(2)` and +/// so are invisible to a syscall filter. Blocking the ring at creation closes +/// that path; programs treat `ENOSYS` as "no io_uring here" and fall back. +#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))] +fn block_inet_sockets() -> Result<(), String> { + let no_inet = SECCOMP_RET_ERRNO | (libc::EAFNOSUPPORT as u32 & 0xffff); + let no_ring = SECCOMP_RET_ERRNO | (libc::ENOSYS as u32 & 0xffff); + + // Jump offsets are relative to the *next* instruction. + let filter = [ + // 0: reject anything running under a different syscall ABI outright. + insn(BPF_LD_W_ABS, 0, 0, OFF_ARCH), + insn(BPF_JMP_JEQ_K, 1, 0, AUDIT_ARCH), + insn(BPF_RET_K, 0, 0, SECCOMP_RET_KILL_PROCESS), + // 3: dispatch on the syscall number. + insn(BPF_LD_W_ABS, 0, 0, OFF_NR), + insn(BPF_JMP_JEQ_K, 5, 0, libc::SYS_io_uring_setup as u32), // -> no_ring + insn(BPF_JMP_JEQ_K, 0, 6, libc::SYS_socket as u32), // else -> allow + // 6: socket(2) - inspect the address family. + insn(BPF_LD_W_ABS, 0, 0, OFF_ARG0), + insn(BPF_JMP_JEQ_K, 3, 0, libc::AF_INET as u32), + insn(BPF_JMP_JEQ_K, 2, 0, libc::AF_INET6 as u32), + insn(BPF_JMP_JEQ_K, 1, 2, libc::AF_PACKET as u32), + // 10: verdicts. + insn(BPF_RET_K, 0, 0, no_ring), + insn(BPF_RET_K, 0, 0, no_inet), + insn(BPF_RET_K, 0, 0, SECCOMP_RET_ALLOW), + ]; + + // A seccomp filter may only be installed with no_new_privs set. Landlock + // sets it too, but the filter goes on first. + if unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } != 0 { + return Err(format!( + "failed to set no_new_privs: {}", + io::Error::last_os_error() + )); + } + + let prog = libc::sock_fprog { + len: filter.len() as u16, + filter: filter.as_ptr() as *mut libc::sock_filter, + }; + let rc = unsafe { + libc::syscall( + libc::SYS_seccomp, + SECCOMP_SET_MODE_FILTER, + 0, + &prog as *const libc::sock_fprog, + ) + }; + if rc != 0 { + return Err(format!( + "seccomp filter rejected: {}", + io::Error::last_os_error() + )); + } + Ok(()) +} + +#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))] +fn block_inet_sockets() -> Result<(), String> { + Err("no seccomp filter is defined for this architecture".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn online_applies_nothing() { + assert_eq!( + apply(NetworkMode::Online).unwrap(), + Enforcement::Unrestricted + ); + } + + /// The probe must leave the caller's own namespaces untouched, or every + /// later `sx` run would inherit a namespace it never asked for. + #[test] + fn probing_does_not_disturb_the_caller() { + let before = std::fs::read_to_string("/proc/self/ns/net").ok(); + let interfaces_before = std::fs::read_to_string("/proc/net/dev").unwrap(); + + let first = namespace_usable(false); + let second = namespace_usable(true); + + assert_eq!(first, namespace_usable(false), "probe is not deterministic"); + assert_eq!(second, namespace_usable(true), "probe is not deterministic"); + assert_eq!(before, std::fs::read_to_string("/proc/self/ns/net").ok()); + assert_eq!( + interfaces_before, + std::fs::read_to_string("/proc/net/dev").unwrap(), + "probing changed the caller's network namespace" + ); + } + + #[test] + fn enforcement_descriptions_are_distinct() { + assert_ne!( + Enforcement::Namespace { loopback: true }.to_string(), + Enforcement::Namespace { loopback: false }.to_string() + ); + assert!(Enforcement::Seccomp.to_string().contains("seccomp")); + } + + /// Verifies the BPF program in a throwaway child: the filter is + /// irreversible, so it cannot be installed in the test process itself. + /// + /// Exit codes identify which check failed (see the match arms below). + #[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))] + #[test] + fn seccomp_fallback_blocks_network_paths_but_keeps_unix() { + let mut status = 0; + let pid = unsafe { libc::fork() }; + assert!(pid >= 0, "fork failed"); + if pid == 0 { + let code = match block_inet_sockets() { + Err(_) => 10, + Ok(()) => { + let inet = unsafe { libc::socket(libc::AF_INET, libc::SOCK_STREAM, 0) }; + let inet6 = unsafe { libc::socket(libc::AF_INET6, libc::SOCK_STREAM, 0) }; + let unix = unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0) }; + let ring = unsafe { + libc::syscall( + libc::SYS_io_uring_setup, + 1, + std::ptr::null::(), + ) + }; + let ring_blocked = + ring < 0 && io::Error::last_os_error().raw_os_error() == Some(libc::ENOSYS); + + if inet >= 0 { + 11 // AF_INET was allowed through + } else if inet6 >= 0 { + 12 // AF_INET6 was allowed through + } else if unix < 0 { + 13 // AF_UNIX was wrongly blocked + } else if !ring_blocked { + 14 // io_uring could still be set up + } else { + 0 + } + } + }; + unsafe { libc::_exit(code) } + } + unsafe { libc::waitpid(pid, &mut status, 0) }; + assert!(libc::WIFEXITED(status), "child did not exit normally"); + assert_eq!(libc::WEXITSTATUS(status), 0, "seccomp filter misbehaved"); + } +} diff --git a/src/sandbox/linux/policy.rs b/src/sandbox/linux/policy.rs new file mode 100644 index 0000000..504d0b1 --- /dev/null +++ b/src/sandbox/linux/policy.rs @@ -0,0 +1,212 @@ +//! Human-readable rendering of the Linux policy, for `sx --dry-run`. +//! +//! The Seatbelt backend prints the profile it hands to `sandbox-exec`; this is +//! the equivalent for Landlock - the resolved rule set, plus the parts of the +//! configuration that Linux enforces differently. + +use crate::config::schema::{ExecSugid, NetworkMode}; +use crate::sandbox::linux::landlock::Support; +use crate::sandbox::linux::net; +use crate::sandbox::linux::rules::{self, Rule, LIST, READ, WRITE}; +use crate::sandbox::params::SandboxParams; +use std::fmt::Write; + +/// Render the policy `sx` would enforce for these parameters. +pub fn render(params: &SandboxParams) -> String { + let support = Support::detect(); + let out_rules = rules::build(params); + render_with(params, &out_rules, support, net::namespace_usable(false)) +} + +/// Rendering split from probing so it can be tested deterministically. +fn render_with( + params: &SandboxParams, + out_rules: &[Rule], + support: Support, + userns: bool, +) -> String { + let mut out = String::new(); + + out.push_str("# sx sandbox policy (landlock)\n"); + for note in notes_with(params, support, userns) { + let _ = writeln!(out, "# {}", note); + } + + if !params.deny_read.is_empty() { + out.push_str("\n# denied for reading (no rule is emitted for these paths)\n"); + for path in ¶ms.deny_read { + let _ = writeln!(out, "# deny {}", path.display()); + } + } + + out.push_str("\n# r = read files + execute, l = list directory, w = create/modify/delete\n"); + for rule in out_rules { + let _ = writeln!(out, "{} {}", access_str(rule.access), rule.path.display()); + } + + out +} + +/// Short statements about what this kernel will and will not enforce. +/// +/// Shared by `--dry-run` (as comment lines) and `--explain`. +pub fn notes(params: &SandboxParams) -> Vec { + notes_with(params, Support::detect(), net::namespace_usable(false)) +} + +fn notes_with(params: &SandboxParams, support: Support, userns: bool) -> Vec { + let mut notes = Vec::new(); + + if support.available() { + notes.push(format!("kernel Landlock ABI: {}", support.abi)); + for gap in support.missing() { + notes.push(format!("not enforced on this kernel: {}", gap)); + } + } else { + notes.push( + "WARNING: this kernel does not support Landlock; sx will refuse to run".to_string(), + ); + } + + notes.push(format!( + "network: {} -> {}", + mode_name(params.network_mode), + network_plan(params.network_mode, userns) + )); + notes.push("setuid/setgid execution: never elevates (no_new_privs is always set)".to_string()); + + if let ExecSugid::Paths(paths) = ¶ms.allow_exec_sugid { + if !paths.is_empty() { + notes.push(format!( + "note: allow_exec_sugid has no effect on Linux (ignored: {})", + paths.join(", ") + )); + } + } + if params.raw_rules.is_some() { + notes.push("note: raw seatbelt rules are ignored on Linux".to_string()); + } + if !params.deny_read.is_empty() { + notes.push( + "note: deny_read removes file-content access; directory names under a denied \ + path may still be listable when a parent grants listing" + .to_string(), + ); + } + + notes +} + +fn access_str(access: u8) -> String { + let mut s = String::with_capacity(3); + s.push(if access & READ != 0 { 'r' } else { '-' }); + s.push(if access & LIST != 0 { 'l' } else { '-' }); + s.push(if access & WRITE != 0 { 'w' } else { '-' }); + s +} + +fn mode_name(mode: NetworkMode) -> &'static str { + match mode { + NetworkMode::Offline => "offline", + NetworkMode::Online => "online", + NetworkMode::Localhost => "localhost", + } +} + +fn network_plan(mode: NetworkMode, userns: bool) -> String { + match (mode, userns) { + (NetworkMode::Online, _) => "unrestricted".to_string(), + (NetworkMode::Offline, true) => "private network namespace".to_string(), + (NetworkMode::Offline, false) => "seccomp fallback (no usable user namespace)".to_string(), + (NetworkMode::Localhost, true) => "private network namespace with loopback".to_string(), + (NetworkMode::Localhost, false) => { + "UNAVAILABLE - no usable user namespace on this system".to_string() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn sample() -> SandboxParams { + SandboxParams { + working_dir: PathBuf::from("/home/u/app"), + network_mode: NetworkMode::Offline, + allow_read: vec![PathBuf::from("/usr")], + ..Default::default() + } + } + + #[test] + fn renders_access_bits() { + assert_eq!(access_str(READ | LIST | WRITE), "rlw"); + assert_eq!(access_str(READ), "r--"); + assert_eq!(access_str(LIST), "-l-"); + assert_eq!(access_str(WRITE), "--w"); + assert_eq!(access_str(0), "---"); + } + + #[test] + fn lists_rules_and_working_dir() { + let params = sample(); + let out = render_with(¶ms, &rules::build(¶ms), Support { abi: 9 }, true); + assert!(out.contains("rl- /usr")); + assert!(out.contains("rlw /home/u/app")); + assert!(out.contains("kernel Landlock ABI: 9")); + } + + #[test] + fn warns_when_landlock_is_missing() { + let params = sample(); + let out = render_with(¶ms, &[], Support { abi: 0 }, true); + assert!(out.contains("does not support Landlock")); + } + + #[test] + fn reports_seccomp_fallback_without_user_namespaces() { + let params = sample(); + let out = render_with(¶ms, &[], Support { abi: 9 }, false); + assert!(out.contains("seccomp fallback")); + } + + #[test] + fn localhost_without_user_namespaces_is_flagged_unavailable() { + let params = SandboxParams { + network_mode: NetworkMode::Localhost, + ..sample() + }; + let out = render_with(¶ms, &[], Support { abi: 9 }, false); + assert!(out.contains("UNAVAILABLE")); + } + + #[test] + fn denied_paths_are_listed_for_auditing() { + let params = SandboxParams { + deny_read: vec![PathBuf::from("/home/u/.aws")], + ..sample() + }; + let out = render_with(¶ms, &[], Support { abi: 9 }, true); + assert!(out.contains("# deny /home/u/.aws")); + } + + #[test] + fn notes_ignored_macos_only_settings() { + let params = SandboxParams { + allow_exec_sugid: ExecSugid::Paths(vec!["/bin/ps".into()]), + raw_rules: Some("(allow foo)".into()), + ..sample() + }; + let out = render_with(¶ms, &[], Support { abi: 9 }, true); + assert!(out.contains("allow_exec_sugid has no effect on Linux")); + assert!(out.contains("raw seatbelt rules are ignored")); + } + + #[test] + fn old_kernel_gaps_are_listed() { + let params = sample(); + let out = render_with(¶ms, &[], Support { abi: 1 }, true); + assert!(out.contains("signal scoping")); + } +} diff --git a/src/sandbox/linux/rules.rs b/src/sandbox/linux/rules.rs new file mode 100644 index 0000000..c775330 --- /dev/null +++ b/src/sandbox/linux/rules.rs @@ -0,0 +1,533 @@ +//! Translate [`SandboxParams`] into a concrete list of Landlock path rules. +//! +//! Landlock is an **allow-list only** mechanism: there are no deny rules and no +//! last-match-wins ordering like Seatbelt. Denies are therefore emulated by +//! *subtraction* - when an allowed hierarchy contains a denied path, the +//! hierarchy is expanded into its siblings so the denied subtree simply never +//! receives a rule. +//! +//! Deliberate parity choices with the Seatbelt backend: +//! +//! - `deny_read` carves out **file contents** (`READ`), not directory listings. +//! Landlock rules are always hierarchical, so granting listing on a parent +//! necessarily grants it on children. Names under a denied path may be +//! listable; contents never are. +//! - `deny_read` does not restrict `allow_write`, mirroring Seatbelt where +//! `deny_read` only emits `(deny file-read* ...)`. +//! - The working directory is applied last and outranks `deny_read`, mirroring +//! Seatbelt's rule order where the working-dir `(allow file* ...)` comes after +//! the deny block. +//! +//! Every path is resolved before it is used. Landlock registers a rule against +//! the *inode* a path resolves to, so a rule named after a symlink grants +//! access to the symlink's target - which would let a single link inside an +//! allowed directory hand out a denied subtree. +//! +//! Glob patterns are resolved once, when the policy is built. For `deny_read` +//! that would fail *open*: a pattern matching nothing yet leaves its directory +//! granted wholesale, so a file created afterwards would be readable. Such a +//! directory is therefore expanded entry by entry, which leaves anything +//! created later outside every rule. + +use crate::sandbox::params::SandboxParams; +use std::collections::{BTreeMap, HashSet}; +use std::path::{Path, PathBuf}; + +/// Read file contents and execute binaries. +pub const READ: u8 = 1 << 0; +/// List directory entries (`readdir`). +pub const LIST: u8 = 1 << 1; +/// Create, modify, delete, rename and truncate. +pub const WRITE: u8 = 1 << 2; + +/// A resolved Landlock rule: one path hierarchy plus the rights granted on it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Rule { + pub path: PathBuf, + pub access: u8, +} + +/// Character devices a shell needs to function at all. +/// +/// Mirrors the hardcoded device section of the Seatbelt profile so that +/// `inherit_base = false` still leaves a usable terminal. +const ESSENTIAL_DEVICES: &[(&str, u8)] = &[ + ("/dev/null", READ | WRITE), + ("/dev/zero", READ | WRITE), + ("/dev/full", READ | WRITE), + ("/dev/random", READ), + ("/dev/urandom", READ), + ("/dev/tty", READ | WRITE), + ("/dev/ptmx", READ | WRITE), + ("/dev/pts", READ | WRITE | LIST), + ("/dev/fd", READ | LIST), +]; + +/// Build the full rule set for these parameters. +pub fn build(params: &SandboxParams) -> Vec { + let mut acc: BTreeMap = BTreeMap::new(); + let denies = resolve_all(expand_globs(¶ms.deny_read)); + let watch = glob_watch_dirs(¶ms.deny_read); + + for (path, access) in ESSENTIAL_DEVICES { + grant(&mut acc, PathBuf::from(path), *access); + } + + // Readable paths: listing is granted on the hierarchy, file contents are + // carved around any denied subpath. + for path in resolve_all(expand_globs(¶ms.allow_read)) { + if is_denied(&path, &denies) { + continue; + } + grant(&mut acc, path.clone(), LIST); + for carved in carve(&path, &denies, &watch) { + grant(&mut acc, carved, READ); + } + } + + // Listing-only paths (e.g. Bun's module resolution walk). + for path in resolve_all(expand_globs(¶ms.allow_list_dirs)) { + if is_denied(&path, &denies) { + continue; + } + grant(&mut acc, path, LIST); + } + + // Writable paths. Not carved: `deny_read` is a read policy on both backends. + for path in resolve_all(expand_globs(¶ms.allow_write)) { + grant(&mut acc, path, WRITE); + } + + // Working directory last: full access, outranking deny_read. + if !params.working_dir.as_os_str().is_empty() { + grant(&mut acc, resolve(¶ms.working_dir), READ | LIST | WRITE); + } + + acc.into_iter() + .map(|(path, access)| Rule { path, access }) + .collect() +} + +/// Resolve a path to the inode Landlock will actually match on. +/// +/// Falls back to the literal path when it does not exist: profiles legitimately +/// list optional paths, and a rule for a missing path is simply skipped later. +fn resolve(path: &Path) -> PathBuf { + std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} + +fn resolve_all(paths: Vec) -> Vec { + paths.iter().map(|p| resolve(p)).collect() +} + +fn has_glob(path: &Path) -> bool { + let as_str = path.to_string_lossy(); + as_str.contains('*') || as_str.contains('?') +} + +/// Directories where a `deny_read` glob could still match a file that does not +/// exist yet, and which therefore must never be granted as a whole. +fn glob_watch_dirs(patterns: &[PathBuf]) -> Vec { + patterns + .iter() + .filter(|p| has_glob(p)) + .filter_map(|p| { + let literal_prefix: PathBuf = p + .components() + .take_while(|c| !has_glob(Path::new(c.as_os_str()))) + .collect(); + (!literal_prefix.as_os_str().is_empty()).then(|| resolve(&literal_prefix)) + }) + .collect() +} + +/// True when `dir` contains something that must not be granted wholesale. +fn needs_carving(dir: &Path, denies: &[PathBuf], watch: &[PathBuf]) -> bool { + denies + .iter() + .any(|d| d.as_path() != dir && d.starts_with(dir)) + || watch.iter().any(|w| w.starts_with(dir)) +} + +fn grant(acc: &mut BTreeMap, path: PathBuf, access: u8) { + *acc.entry(path).or_insert(0) |= access; +} + +/// True when `path` is at or below any denied path. +fn is_denied(path: &Path, denies: &[PathBuf]) -> bool { + denies.iter().any(|d| path.starts_with(d)) +} + +/// Expand `root` into the largest set of subtrees that excludes every denied +/// path beneath it. +/// +/// Returns `[root]` untouched when nothing under it is denied - the common case, +/// since the default profile's denies sit outside the allowed hierarchies and +/// only start to matter once a user allows their whole home directory. +fn carve(root: &Path, denies: &[PathBuf], watch: &[PathBuf]) -> Vec { + if !needs_carving(root, denies, watch) { + return vec![root.to_path_buf()]; + } + + let mut out = Vec::new(); + let mut visited = HashSet::new(); + expand_around(root, denies, watch, &mut visited, &mut out); + out +} + +/// Emit rules covering `dir` minus every denied subtree beneath it. +/// +/// Decisions are made on each entry's *resolved* target, so a symlink cannot +/// smuggle a denied directory back in under a different name. `visited` keeps +/// symlink cycles from recursing forever. +fn expand_around( + dir: &Path, + denies: &[PathBuf], + watch: &[PathBuf], + visited: &mut HashSet, + out: &mut Vec, +) { + if !visited.insert(dir.to_path_buf()) { + return; + } + + // Fail closed: a directory we cannot enumerate contributes no rules. + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + + for entry in entries.flatten() { + let target = resolve(&entry.path()); + if is_denied(&target, denies) { + continue; // the denied subtree, however it was reached + } + if needs_carving(&target, denies, watch) { + expand_around(&target, denies, watch, visited, out); + } else { + out.push(target); + } + } +} + +/// Resolve glob patterns against the filesystem. +/// +/// Landlock rules reference concrete inodes, so patterns are resolved once when +/// the policy is built. Paths created later do not match - unlike Seatbelt, +/// which evaluates its regex filters at access time. +fn expand_globs(paths: &[PathBuf]) -> Vec { + let mut out = Vec::new(); + for path in paths { + let as_str = path.to_string_lossy(); + if !as_str.contains('*') && !as_str.contains('?') { + out.push(path.clone()); + continue; + } + if let Ok(matches) = glob::glob(&as_str) { + out.extend(matches.flatten()); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + /// Named indirectly so the literal path never appears in the source. + const SECRET_DIR: &str = ".ssh"; + + fn params_with(allow_read: Vec, deny_read: Vec) -> SandboxParams { + SandboxParams { + allow_read, + deny_read, + ..Default::default() + } + } + + fn access_of(rules: &[Rule], path: &Path) -> Option { + rules.iter().find(|r| r.path == path).map(|r| r.access) + } + + #[test] + fn no_denies_keeps_hierarchy_intact() { + let params = params_with(vec![PathBuf::from("/usr")], vec![]); + let rules = build(¶ms); + assert_eq!(access_of(&rules, Path::new("/usr")), Some(READ | LIST)); + } + + #[test] + fn deny_outside_allowed_tree_does_not_carve() { + let params = params_with( + vec![PathBuf::from("/usr")], + vec![PathBuf::from("/home/u").join(SECRET_DIR)], + ); + let rules = build(¶ms); + assert_eq!(access_of(&rules, Path::new("/usr")), Some(READ | LIST)); + } + + #[test] + fn deny_inside_allowed_tree_carves_into_siblings() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path(); + fs::create_dir(home.join(SECRET_DIR)).unwrap(); + fs::create_dir(home.join("projects")).unwrap(); + fs::write(home.join("notes.txt"), "hi").unwrap(); + + let params = params_with(vec![home.to_path_buf()], vec![home.join(SECRET_DIR)]); + let rules = build(¶ms); + + // The secret keeps no read rule at all. + assert_eq!(access_of(&rules, &home.join(SECRET_DIR)), None); + // Siblings stay readable. + assert_eq!(access_of(&rules, &home.join("projects")), Some(READ)); + assert_eq!(access_of(&rules, &home.join("notes.txt")), Some(READ)); + // The parent stays listable so `ls ~` still works. + assert_eq!(access_of(&rules, home), Some(LIST)); + } + + #[test] + fn nested_deny_carves_each_level() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path(); + fs::create_dir_all(home.join("config/secrets")).unwrap(); + fs::create_dir(home.join("config/public")).unwrap(); + fs::create_dir(home.join("other")).unwrap(); + + let params = params_with(vec![home.to_path_buf()], vec![home.join("config/secrets")]); + let rules = build(¶ms); + + assert_eq!(access_of(&rules, &home.join("config/secrets")), None); + assert_eq!(access_of(&rules, &home.join("config/public")), Some(READ)); + assert_eq!(access_of(&rules, &home.join("other")), Some(READ)); + // The intermediate directory is not granted wholesale. + assert_eq!(access_of(&rules, &home.join("config")), None); + } + + #[test] + fn explicit_allow_of_denied_path_is_dropped() { + let secret = PathBuf::from("/home/u").join(SECRET_DIR); + let params = params_with(vec![secret.clone()], vec![secret.clone()]); + let rules = build(¶ms); + assert_eq!(access_of(&rules, &secret), None); + } + + #[test] + fn allow_of_path_under_denied_path_is_dropped() { + let secret = PathBuf::from("/home/u").join(SECRET_DIR); + let key = secret.join("id_rsa"); + let params = params_with(vec![key.clone()], vec![secret]); + let rules = build(¶ms); + assert_eq!(access_of(&rules, &key), None); + } + + #[test] + fn sibling_prefix_is_not_treated_as_denied() { + // /home/user2 must not be swallowed by a deny on /home/user + let params = params_with( + vec![PathBuf::from("/home/user2")], + vec![PathBuf::from("/home/user")], + ); + let rules = build(¶ms); + assert_eq!( + access_of(&rules, Path::new("/home/user2")), + Some(READ | LIST) + ); + } + + #[test] + fn working_dir_gets_full_access_and_outranks_deny() { + let params = SandboxParams { + working_dir: PathBuf::from("/home/u/Documents/app"), + deny_read: vec![PathBuf::from("/home/u/Documents")], + ..Default::default() + }; + let rules = build(¶ms); + assert_eq!( + access_of(&rules, Path::new("/home/u/Documents/app")), + Some(READ | LIST | WRITE) + ); + } + + #[test] + fn write_paths_are_not_carved_by_deny_read() { + let params = SandboxParams { + allow_write: vec![PathBuf::from("/home/u/Documents")], + deny_read: vec![PathBuf::from("/home/u/Documents")], + ..Default::default() + }; + let rules = build(¶ms); + assert_eq!( + access_of(&rules, Path::new("/home/u/Documents")), + Some(WRITE) + ); + } + + #[test] + fn list_dirs_grant_listing_without_read() { + let params = SandboxParams { + allow_list_dirs: vec![PathBuf::from("/home")], + ..Default::default() + }; + let rules = build(¶ms); + assert_eq!(access_of(&rules, Path::new("/home")), Some(LIST)); + } + + #[test] + fn essential_devices_are_always_present() { + let rules = build(&SandboxParams::default()); + assert_eq!( + access_of(&rules, Path::new("/dev/null")), + Some(READ | WRITE) + ); + assert_eq!( + access_of(&rules, Path::new("/dev/pts")), + Some(READ | WRITE | LIST) + ); + } + + /// Landlock registers rules against resolved inodes, so a link named + /// outside a denied subtree must not hand that subtree back. + #[test] + fn symlink_into_a_denied_subtree_is_not_granted() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path(); + fs::create_dir(home.join("private")).unwrap(); + fs::create_dir(home.join("public")).unwrap(); + std::os::unix::fs::symlink(home.join("private"), home.join("shortcut")).unwrap(); + + let params = params_with(vec![home.to_path_buf()], vec![home.join("private")]); + let rules = build(¶ms); + + let private = fs::canonicalize(home.join("private")).unwrap(); + assert!( + rules.iter().all(|r| r.path != private), + "a rule pointed at the denied directory: {:?}", + rules + ); + assert_eq!( + access_of(&rules, &fs::canonicalize(home.join("public")).unwrap()), + Some(READ) + ); + } + + /// A link whose target still contains a denied path has to be carved too, + /// not granted wholesale - and only once, even when the same directory is + /// reachable under two names. + #[test] + fn symlink_to_a_directory_containing_a_deny_is_carved() { + let tmp = TempDir::new().unwrap(); + let root = fs::canonicalize(tmp.path()).unwrap(); + fs::create_dir_all(root.join("real/private")).unwrap(); + fs::create_dir_all(root.join("real/public")).unwrap(); + std::os::unix::fs::symlink(root.join("real"), root.join("link")).unwrap(); + + let params = params_with(vec![root.clone()], vec![root.join("real/private")]); + let rules = build(¶ms); + + assert!( + rules.iter().all(|r| r.path != root.join("real")), + "granted the whole directory despite a deny inside it" + ); + assert!( + rules.iter().all(|r| r.path != root.join("link")), + "granted the link, which resolves onto the denied hierarchy" + ); + assert_eq!(access_of(&rules, &root.join("real/public")), Some(READ)); + assert_eq!(access_of(&rules, &root.join("real/private")), None); + } + + #[test] + fn symlink_cycle_terminates() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path(); + fs::create_dir(home.join("private")).unwrap(); + fs::create_dir(home.join("sub")).unwrap(); + std::os::unix::fs::symlink(home, home.join("sub/up")).unwrap(); + + // Would recurse forever without cycle detection. + let params = params_with(vec![home.to_path_buf()], vec![home.join("private")]); + let rules = build(¶ms); + + let private = fs::canonicalize(home.join("private")).unwrap(); + assert!(rules.iter().all(|r| r.path != private)); + } + + #[test] + fn globs_resolve_to_existing_matches() { + let tmp = TempDir::new().unwrap(); + fs::create_dir(tmp.path().join("claude-501")).unwrap(); + fs::create_dir(tmp.path().join("other")).unwrap(); + + let params = params_with(vec![tmp.path().join("claude*")], vec![]); + let rules = build(¶ms); + + assert_eq!( + access_of(&rules, &tmp.path().join("claude-501")), + Some(READ | LIST) + ); + assert_eq!(access_of(&rules, &tmp.path().join("other")), None); + } + + /// A deny pattern that matches nothing yet must still stop its directory + /// from being granted wholesale, or a file created later would be readable. + #[test] + fn deny_glob_with_no_current_match_still_carves_its_directory() { + let tmp = TempDir::new().unwrap(); + let home = fs::canonicalize(tmp.path()).unwrap(); + fs::create_dir(home.join("keep")).unwrap(); + + let params = params_with(vec![home.clone()], vec![home.join("secret*")]); + let rules = build(¶ms); + + assert_eq!( + access_of(&rules, &home), + Some(LIST), + "the watched directory must not be granted for reading as a whole" + ); + assert_eq!(access_of(&rules, &home.join("keep")), Some(READ)); + } + + #[test] + fn deny_glob_excludes_its_current_matches() { + let tmp = TempDir::new().unwrap(); + let home = fs::canonicalize(tmp.path()).unwrap(); + fs::write(home.join("secret1"), "x").unwrap(); + fs::write(home.join("keep.txt"), "x").unwrap(); + + let params = params_with(vec![home.clone()], vec![home.join("secret*")]); + let rules = build(¶ms); + + assert_eq!(access_of(&rules, &home.join("secret1")), None); + assert_eq!(access_of(&rules, &home.join("keep.txt")), Some(READ)); + } + + #[test] + fn deny_glob_outside_the_allowed_tree_does_not_carve_it() { + let tmp = TempDir::new().unwrap(); + let home = fs::canonicalize(tmp.path()).unwrap(); + fs::create_dir(home.join("keep")).unwrap(); + + // The pattern lives somewhere else entirely, so nothing here changes. + let params = params_with( + vec![home.clone()], + vec![PathBuf::from("/elsewhere/secret*")], + ); + let rules = build(¶ms); + + assert_eq!(access_of(&rules, &home), Some(READ | LIST)); + } + + #[test] + fn unenumerable_directory_fails_closed() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path().join("missing"); + let params = params_with(vec![root.clone()], vec![root.join("secret")]); + let rules = build(¶ms); + // Listing is still requested, but no read rule is invented for a + // directory we could not walk. + assert_eq!(access_of(&rules, &root), Some(LIST)); + assert!(rules.iter().all(|r| r.access & READ == 0 || r.path != root)); + } +} diff --git a/src/sandbox/mod.rs b/src/sandbox/mod.rs index 5a15303..9e87054 100644 --- a/src/sandbox/mod.rs +++ b/src/sandbox/mod.rs @@ -1,4 +1,11 @@ +pub mod backend; pub mod executor; +pub mod params; pub mod seatbelt; pub mod trace; pub mod violations; + +#[cfg(target_os = "linux")] +pub mod linux; + +pub use params::SandboxParams; diff --git a/src/sandbox/params.rs b/src/sandbox/params.rs new file mode 100644 index 0000000..6977acc --- /dev/null +++ b/src/sandbox/params.rs @@ -0,0 +1,46 @@ +//! Platform-neutral sandbox parameters. +//! +//! `SandboxParams` is the resolved intermediate representation produced by the +//! config/profile layer and consumed by a platform backend (Seatbelt on macOS, +//! Landlock on Linux). It is serialisable so the Linux backend can hand it to +//! the sandbox helper process. + +use crate::config::schema::{ExecSugid, NetworkMode}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// Parameters for generating a sandbox policy +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct SandboxParams { + /// Working directory (project root) - gets full read/write access + pub working_dir: PathBuf, + /// Home directory + pub home_dir: PathBuf, + /// Network mode (offline, online, localhost) + pub network_mode: NetworkMode, + /// Paths to allow reading (deny-by-default, only these paths are accessible) + pub allow_read: Vec, + /// Paths to explicitly deny reading (overrides allow_read, for sensitive subpaths) + pub deny_read: Vec, + /// Paths to allow writing (restricted by default) + pub allow_write: Vec, + /// Paths to allow directory listing only (readdir), not file contents. + /// + /// On macOS this uses the Seatbelt `literal` filter - only the exact + /// directory is listable, not its children. On Linux, Landlock rules are + /// always hierarchical, so nested directories become listable too (names + /// only; file contents stay denied). + pub allow_list_dirs: Vec, + /// Raw seatbelt rules to include verbatim (macOS only) + #[serde(skip_serializing_if = "Option::is_none")] + pub raw_rules: Option, + /// Allow execution of setuid/setgid binaries + pub allow_exec_sugid: ExecSugid, + /// Environment variables to pass through (glob patterns supported) + pub pass_env: Vec, + /// Environment variables to deny (glob patterns, takes precedence over pass_env) + pub deny_env: Vec, + /// Environment variables to explicitly set + pub set_env: std::collections::HashMap, +} diff --git a/src/sandbox/seatbelt.rs b/src/sandbox/seatbelt.rs index e1b939d..e799ac3 100644 --- a/src/sandbox/seatbelt.rs +++ b/src/sandbox/seatbelt.rs @@ -4,7 +4,6 @@ //! Uses a deny-by-default security model where only explicitly allowed paths are accessible. use crate::config::schema::{ExecSugid, NetworkMode}; -use std::path::PathBuf; /// Error type for seatbelt profile generation #[derive(Debug, Clone, PartialEq, Eq)] @@ -83,36 +82,8 @@ fn glob_to_regex(pattern: &str) -> String { regex } -/// Parameters for generating a Seatbelt sandbox profile -#[derive(Debug, Clone, Default)] -pub struct SandboxParams { - /// Working directory (project root) - gets full read/write access - pub working_dir: PathBuf, - /// Home directory - pub home_dir: PathBuf, - /// Network mode (offline, online, localhost) - pub network_mode: NetworkMode, - /// Paths to allow reading (deny-by-default, only these paths are readable) - pub allow_read: Vec, - /// Paths to explicitly deny reading (overrides allow_read, for sensitive subpaths) - pub deny_read: Vec, - /// Paths to allow writing (restricted by default) - pub allow_write: Vec, - /// Paths to allow directory listing only (readdir), not file contents. - /// Uses Seatbelt `literal` filter - allows listing a directory's entries - /// without granting access to files or subdirectories within it. - pub allow_list_dirs: Vec, - /// Raw seatbelt rules to include verbatim - pub raw_rules: Option, - /// Allow execution of setuid/setgid binaries - pub allow_exec_sugid: ExecSugid, - /// Environment variables to pass through (glob patterns supported) - pub pass_env: Vec, - /// Environment variables to deny (glob patterns, takes precedence over pass_env) - pub deny_env: Vec, - /// Environment variables to explicitly set - pub set_env: std::collections::HashMap, -} +// `SandboxParams` lives in `sandbox::params` so platform backends can share it. +pub use crate::sandbox::params::SandboxParams; /// Generate a Seatbelt profile from the given parameters /// @@ -309,6 +280,7 @@ pub fn generate_seatbelt_profile(params: &SandboxParams) -> Result &'static str { + if cfg!(target_os = "macos") { + "/bin/zsh" + } else { + "/bin/bash" + } +} + +#[cfg(test)] +mod tests { + #[test] + fn default_shell_is_absolute_and_platform_appropriate() { + let shell = super::default_shell(); + assert!(shell.starts_with('/')); + if cfg!(target_os = "macos") { + assert_eq!(shell, "/bin/zsh"); + } else { + assert_eq!(shell, "/bin/bash"); + } + } +} diff --git a/tests/integration.rs b/tests/integration.rs index ba714f5..592afb9 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -21,8 +21,12 @@ use sx::sandbox::executor::execute_sandboxed_captured; use sx::sandbox::seatbelt::{generate_seatbelt_profile, SandboxParams}; use tempfile::TempDir; -/// Check if sandbox-exec with custom deny-default profiles is available -/// On newer macOS versions, custom sandbox profiles with deny-default may be restricted +/// Check if sandbox-exec with custom deny-default profiles is available. +/// +/// On newer macOS versions, custom sandbox profiles with deny-default may be +/// restricted. On Linux this is always false: the Landlock backend launches +/// through the `sx` binary itself, so its end-to-end coverage lives in +/// `tests/linux_sandbox.rs`, which drives the real binary. fn is_custom_sandbox_available() -> bool { // Test with a deny-default profile that should allow basic execution let profile = r#"(version 1) @@ -59,7 +63,13 @@ fn is_custom_sandbox_available() -> bool { macro_rules! skip_if_no_sandbox { () => { if !is_custom_sandbox_available() { - eprintln!("Skipping test: custom sandbox profiles not available on this system"); + if cfg!(target_os = "linux") { + eprintln!( + "Skipping test: Linux sandbox behaviour is covered by tests/linux_sandbox.rs" + ); + } else { + eprintln!("Skipping test: custom sandbox profiles not available on this system"); + } return; } }; diff --git a/tests/linux_sandbox.rs b/tests/linux_sandbox.rs new file mode 100644 index 0000000..e004c9c --- /dev/null +++ b/tests/linux_sandbox.rs @@ -0,0 +1,475 @@ +//! End-to-end Linux sandbox tests. +//! +//! These drive the real `sx` binary rather than the library, because the Linux +//! backend re-execs `sx` itself as its sandbox helper - the same path a user +//! exercises. The macOS equivalents live in `tests/integration.rs`. +#![cfg(target_os = "linux")] + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use tempfile::TempDir; + +const SX_BIN: &str = env!("CARGO_BIN_EXE_sx"); + +fn sx(dir: &Path, args: &[&str]) -> Output { + Command::new(SX_BIN) + .current_dir(dir) + .args(args) + .output() + .expect("failed to run sx") +} + +fn stdout(out: &Output) -> String { + String::from_utf8_lossy(&out.stdout).to_string() +} + +fn stderr(out: &Output) -> String { + String::from_utf8_lossy(&out.stderr).to_string() +} + +/// Ask sx itself whether the kernel enforces Landlock. +fn landlock_available() -> bool { + let out = sx(Path::new("/tmp"), &["--dry-run", "--", "true"]); + stdout(&out).contains("kernel Landlock ABI") +} + +macro_rules! require_landlock { + () => { + if !landlock_available() { + eprintln!("skipping: this kernel does not enforce Landlock"); + return; + } + }; +} + +/// A workspace outside `/tmp`, which the base profile deliberately makes +/// readable and writable. Home is denied by default, so a temp dir there gives +/// a genuine "outside the sandbox" location. +fn workspace() -> TempDir { + let home = dirs::home_dir().expect("home directory"); + TempDir::new_in(home).expect("create workspace") +} + +/// `/work` is the working directory; `/outside` must stay unreachable. +fn split_workspace() -> (TempDir, PathBuf, PathBuf) { + let root = workspace(); + let work = root.path().join("work"); + let outside = root.path().join("outside"); + fs::create_dir(&work).unwrap(); + fs::create_dir(&outside).unwrap(); + fs::write(outside.join("secret.txt"), "TOPSECRET").unwrap(); + (root, work, outside) +} + +#[test] +fn runs_a_command_and_propagates_its_exit_code() { + require_landlock!(); + let root = workspace(); + + let out = sx(root.path(), &["--", "/usr/bin/printf", "sandboxed"]); + assert!(out.status.success(), "stderr: {}", stderr(&out)); + assert_eq!(stdout(&out), "sandboxed"); + + let out = sx(root.path(), &["--", "/usr/bin/bash", "-c", "exit 42"]); + assert_eq!(out.status.code(), Some(42)); +} + +#[test] +fn reports_missing_and_non_executable_commands() { + require_landlock!(); + let root = workspace(); + + let out = sx(root.path(), &["--", "/usr/bin/sx-does-not-exist"]); + assert_eq!(out.status.code(), Some(127)); + + let not_executable = root.path().join("data.txt"); + fs::write(¬_executable, "not a program").unwrap(); + let out = sx(root.path(), &["--", not_executable.to_str().unwrap()]); + assert_eq!(out.status.code(), Some(126)); +} + +#[test] +fn allows_reading_and_writing_inside_the_working_directory() { + require_landlock!(); + let (_root, work, _outside) = split_workspace(); + fs::write(work.join("input.txt"), "hello").unwrap(); + + let out = sx(&work, &["--", "/usr/bin/cat", "input.txt"]); + assert!(out.status.success(), "stderr: {}", stderr(&out)); + assert_eq!(stdout(&out), "hello"); + + let out = sx(&work, &["--", "/usr/bin/touch", "created.txt"]); + assert!(out.status.success(), "stderr: {}", stderr(&out)); + assert!(work.join("created.txt").exists()); +} + +#[test] +fn denies_reading_outside_the_allowlist() { + require_landlock!(); + let (_root, work, outside) = split_workspace(); + + let out = sx( + &work, + &[ + "--", + "/usr/bin/cat", + outside.join("secret.txt").to_str().unwrap(), + ], + ); + assert!( + !out.status.success(), + "reading outside the sandbox succeeded" + ); + assert!(!stdout(&out).contains("TOPSECRET")); +} + +#[test] +fn denies_writing_outside_the_working_directory() { + require_landlock!(); + let (_root, work, outside) = split_workspace(); + let target = outside.join("planted.txt"); + + let out = sx(&work, &["--", "/usr/bin/touch", target.to_str().unwrap()]); + assert!( + !out.status.success(), + "writing outside the sandbox succeeded" + ); + assert!(!target.exists()); +} + +#[test] +fn restrictions_are_inherited_by_child_processes() { + require_landlock!(); + let (_root, work, outside) = split_workspace(); + let secret = outside.join("secret.txt"); + + // bash forks cat: the policy must survive both fork and exec. + let out = sx( + &work, + &[ + "--", + "/usr/bin/bash", + "-c", + &format!("cat {}", secret.display()), + ], + ); + assert!(!out.status.success()); + assert!(!stdout(&out).contains("TOPSECRET")); +} + +#[test] +fn deny_read_carves_out_the_secret_but_keeps_siblings_readable() { + require_landlock!(); + let (root, work, _outside) = split_workspace(); + // Deliberately a sibling of the working directory: the working directory + // itself outranks deny_read (see the test below), so nesting the fixture + // inside it would not exercise the carve-out. + let home = root.path().join("home"); + let secret = home.join("private"); + let public = home.join("public"); + fs::create_dir_all(&secret).unwrap(); + fs::create_dir_all(&public).unwrap(); + fs::write(secret.join("key"), "TOPSECRET").unwrap(); + fs::write(public.join("readme"), "PUBLIC").unwrap(); + + let allow = home.to_str().unwrap(); + let deny = secret.to_str().unwrap(); + + // The sibling stays readable. + let out = sx( + &work, + &[ + "--allow-read", + allow, + "--deny-read", + deny, + "--", + "/usr/bin/cat", + public.join("readme").to_str().unwrap(), + ], + ); + assert!(out.status.success(), "stderr: {}", stderr(&out)); + assert_eq!(stdout(&out), "PUBLIC"); + + // The denied file does not. + let out = sx( + &work, + &[ + "--allow-read", + allow, + "--deny-read", + deny, + "--", + "/usr/bin/cat", + secret.join("key").to_str().unwrap(), + ], + ); + assert!(!out.status.success(), "denied file was readable"); + assert!(!stdout(&out).contains("TOPSECRET")); + + // Listing the parent still works, so `ls ~` is not broken by a deny. + let out = sx( + &work, + &[ + "--allow-read", + allow, + "--deny-read", + deny, + "--", + "/usr/bin/ls", + allow, + ], + ); + assert!(out.status.success(), "stderr: {}", stderr(&out)); + assert!(stdout(&out).contains("public")); +} + +#[test] +fn deny_read_wins_over_an_explicit_allow_of_the_same_path() { + require_landlock!(); + let (root, work, _outside) = split_workspace(); + let vault = root.path().join("vault"); + fs::create_dir(&vault).unwrap(); + fs::write(vault.join("key"), "TOPSECRET").unwrap(); + + let out = sx( + &work, + &[ + "--allow-read", + vault.to_str().unwrap(), + "--deny-read", + vault.to_str().unwrap(), + "--", + "/usr/bin/cat", + vault.join("key").to_str().unwrap(), + ], + ); + assert!( + !out.status.success(), + "deny_read did not override allow_read" + ); + assert!(!stdout(&out).contains("TOPSECRET")); +} + +/// Mirrors Seatbelt, where the working-directory `(allow file* ...)` rule is +/// emitted after the deny block and therefore wins. +#[test] +fn the_working_directory_outranks_deny_read() { + require_landlock!(); + let (_root, work, _outside) = split_workspace(); + let nested = work.join("vault"); + fs::create_dir(&nested).unwrap(); + fs::write(nested.join("key"), "PROJECT_DATA").unwrap(); + + let out = sx( + &work, + &[ + "--deny-read", + nested.to_str().unwrap(), + "--", + "/usr/bin/cat", + nested.join("key").to_str().unwrap(), + ], + ); + assert!(out.status.success(), "stderr: {}", stderr(&out)); + assert_eq!(stdout(&out), "PROJECT_DATA"); +} + +/// True when sx can use a network namespace here; distributions that block +/// unprivileged user namespaces make it fall back to a seccomp filter. +fn uses_network_namespace() -> bool { + let out = sx(Path::new("/tmp"), &["--dry-run", "--", "true"]); + stdout(&out).contains("private network namespace") +} + +/// The property that matters, whichever mechanism ends up enforcing it. +#[test] +fn offline_mode_blocks_network_access() { + require_landlock!(); + let root = workspace(); + + let program = "import socket, sys\n\ + try:\n\ + \x20 s = socket.socket(); s.settimeout(5); s.connect((\"1.1.1.1\", 53))\n\ + \x20 print(\"CONNECTED\")\n\ + except OSError as e:\n\ + \x20 print(\"BLOCKED\", e)\n"; + + let out = sx(root.path(), &["--", "/usr/bin/python3", "-c", program]); + assert!(out.status.success(), "stderr: {}", stderr(&out)); + assert!( + stdout(&out).contains("BLOCKED"), + "offline mode did not block the network: {}", + stdout(&out) + ); +} + +#[test] +fn offline_mode_uses_an_empty_network_namespace() { + require_landlock!(); + if !uses_network_namespace() { + eprintln!("skipping: sx fell back to seccomp, so there is no namespace to inspect"); + return; + } + let root = workspace(); + + let out = sx(root.path(), &["--", "/usr/bin/cat", "/proc/net/dev"]); + assert!(out.status.success(), "stderr: {}", stderr(&out)); + + let interfaces = interface_names(&stdout(&out)); + assert_eq!( + interfaces, + vec!["lo".to_string()], + "offline sandbox should only see loopback" + ); +} + +#[test] +fn online_mode_keeps_the_host_network() { + require_landlock!(); + let root = workspace(); + + let host = interface_names(&fs::read_to_string("/proc/net/dev").unwrap()); + if host.len() <= 1 { + eprintln!("skipping: host itself has no non-loopback interface"); + return; + } + + let out = sx( + root.path(), + &["online", "--", "/usr/bin/cat", "/proc/net/dev"], + ); + assert!(out.status.success(), "stderr: {}", stderr(&out)); + assert_eq!(interface_names(&stdout(&out)), host); +} + +#[test] +fn localhost_mode_allows_private_loopback_only() { + require_landlock!(); + let root = workspace(); + + let program = "import socket\n\ + s = socket.socket(); s.bind((\"127.0.0.1\", 0)); s.listen(1)\n\ + c = socket.socket(); c.connect((\"127.0.0.1\", s.getsockname()[1]))\n\ + print(\"LOOPBACK_OK\")\n"; + + let out = sx( + root.path(), + &["localhost", "--", "/usr/bin/python3", "-c", program], + ); + if !out.status.success() && stderr(&out).contains("user namespace") { + eprintln!("skipping: unprivileged user namespaces are disabled here"); + return; + } + assert!(out.status.success(), "stderr: {}", stderr(&out)); + assert!(stdout(&out).contains("LOOPBACK_OK")); + + // Offline mode must not gain loopback as a side effect. + let out = sx(root.path(), &["--", "/usr/bin/python3", "-c", program]); + assert!(!out.status.success(), "offline mode reached loopback"); +} + +#[test] +fn no_new_privs_is_always_set() { + require_landlock!(); + let root = workspace(); + + let out = sx( + root.path(), + &["--", "/usr/bin/grep", "NoNewPrivs", "/proc/self/status"], + ); + assert!(out.status.success(), "stderr: {}", stderr(&out)); + assert!( + stdout(&out).contains("NoNewPrivs:\t1"), + "expected NoNewPrivs=1, got {:?}", + stdout(&out) + ); +} + +#[test] +fn dry_run_renders_the_landlock_policy_without_running_anything() { + let root = workspace(); + let marker = root.path().join("side-effect"); + + let out = sx( + root.path(), + &[ + "--dry-run", + "--", + "/usr/bin/touch", + marker.to_str().unwrap(), + ], + ); + assert!(out.status.success(), "stderr: {}", stderr(&out)); + + let policy = stdout(&out); + assert!(policy.contains("# sx sandbox policy (landlock)")); + assert!(policy.contains("r = read files + execute")); + assert!(!marker.exists(), "--dry-run executed the command"); +} + +#[test] +fn explain_reports_the_landlock_backend() { + let root = workspace(); + let out = sx(root.path(), &["--explain"]); + assert!(out.status.success(), "stderr: {}", stderr(&out)); + + let text = stdout(&out); + assert!(text.contains("Backend: landlock")); + assert!(text.contains("no_new_privs")); +} + +#[test] +fn the_sandbox_helper_refuses_incomplete_invocations() { + let out = Command::new(SX_BIN) + .arg("--sandbox-apply") + .output() + .expect("failed to run sx"); + assert_eq!(out.status.code(), Some(2)); + assert!(stderr(&out).contains("usage")); + + // Without the policy in the environment there is nothing to enforce, so the + // helper must refuse rather than exec the command unsandboxed. + let out = Command::new(SX_BIN) + .args(["--sandbox-apply", "/usr/bin/true"]) + .env_remove("SX_SANDBOX_SPEC") + .output() + .expect("failed to run sx"); + assert_eq!(out.status.code(), Some(2)); + assert!(stderr(&out).contains("SX_SANDBOX_SPEC")); + + let out = Command::new(SX_BIN) + .args(["--sandbox-apply", "/usr/bin/true"]) + .env("SX_SANDBOX_SPEC", "this is not valid toml {{{") + .output() + .expect("failed to run sx"); + assert_eq!(out.status.code(), Some(2)); + assert!(stderr(&out).contains("sandbox spec")); +} + +/// The policy must not leak into the sandboxed program's environment, and it +/// must not be handed over through a file the sandbox itself can write to. +#[test] +fn the_policy_is_not_visible_to_the_sandboxed_program() { + require_landlock!(); + let root = workspace(); + + let out = sx(root.path(), &["--", "/usr/bin/env"]); + assert!(out.status.success(), "stderr: {}", stderr(&out)); + assert!( + !stdout(&out).contains("SX_SANDBOX_SPEC"), + "the sandboxed program inherited the policy" + ); +} + +/// Interface names from a `/proc/net/dev` dump, in file order. +fn interface_names(proc_net_dev: &str) -> Vec { + proc_net_dev + .lines() + .skip(2) + .filter_map(|line| line.split(':').next()) + .map(|name| name.trim().to_string()) + .filter(|name| !name.is_empty()) + .collect() +} diff --git a/tests/profile_test.rs b/tests/profile_test.rs index c5b12d4..6b9796c 100644 --- a/tests/profile_test.rs +++ b/tests/profile_test.rs @@ -148,7 +148,7 @@ fn test_compose_profiles_empty() { #[test] fn test_compose_profiles_single() { let profile = BuiltinProfile::Base.load().unwrap(); - let composed = compose_profiles(&[profile.clone()]); + let composed = compose_profiles(std::slice::from_ref(&profile)); assert_eq!( composed.filesystem.allow_read, profile.filesystem.allow_read @@ -241,10 +241,15 @@ fn test_builtin_profile_opencode() { .filesystem .allow_read .contains(&"~/.cache/opencode".to_string())); + // The macOS per-session temp dir is declared under [platform.macos] and is + // folded in only when building for macOS. + #[cfg(target_os = "macos")] assert!(profile .filesystem .allow_list_dirs .contains(&"/private/tmp".to_string())); + #[cfg(not(target_os = "macos"))] + assert!(profile.filesystem.allow_list_dirs.is_empty()); assert!(profile .filesystem .allow_write diff --git a/tests/seatbelt_test.rs b/tests/seatbelt_test.rs index 27fa4fe..dec1143 100644 --- a/tests/seatbelt_test.rs +++ b/tests/seatbelt_test.rs @@ -174,8 +174,8 @@ fn test_base_profile_integration() { let composed = compose_profiles(&[base]); let expand_path = |p: &str| -> PathBuf { - if p.starts_with("~/") { - PathBuf::from("/Users/test").join(&p[2..]) + if let Some(rest) = p.strip_prefix("~/") { + PathBuf::from("/Users/test").join(rest) } else { PathBuf::from(p) } diff --git a/tests/signal_test.rs b/tests/signal_test.rs index 24a6bb7..f0b11e4 100644 --- a/tests/signal_test.rs +++ b/tests/signal_test.rs @@ -1,8 +1,13 @@ //! Integration tests for signal forwarding (issue #37). //! //! Verifies that `sx` forwards SIGINT/SIGTERM/SIGHUP to the entire sandboxed -//! process subtree so descendants are not orphaned to launchd when `sx` exits. +//! process subtree so descendants are not orphaned when `sx` exits. +//! +//! Process supervision is shared between the Seatbelt and Landlock backends, so +//! these run on both platforms - on Linux they additionally prove that signals +//! reach through the `--sandbox-apply` helper. +#[cfg(target_os = "macos")] use std::fs; use std::process::{Command, Stdio}; use std::thread; @@ -11,9 +16,20 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; /// Path to the `sx` binary produced by Cargo for this test target. const SX_BIN: &str = env!("CARGO_BIN_EXE_sx"); -/// Probe whether `sandbox-exec` accepts a custom deny-default profile on this -/// system. On hardened macOS configurations custom profiles can be blocked, -/// in which case there is nothing meaningful to assert about signal forwarding. +/// Probe whether this system can actually run a sandboxed command. On hardened +/// macOS configurations custom Seatbelt profiles can be blocked, and a kernel +/// without Landlock cannot enforce anything - in either case there is nothing +/// meaningful to assert about signal forwarding. +#[cfg(not(target_os = "macos"))] +fn is_custom_sandbox_available() -> bool { + Command::new(SX_BIN) + .args(["--no-config", "--", "/bin/echo", "ok"]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +#[cfg(target_os = "macos")] fn is_custom_sandbox_available() -> bool { let probe = r#"(version 1) (deny default) @@ -120,7 +136,7 @@ fn test_sigterm_to_sx_propagates_to_sandbox_subtree() { .expect("spawn sx"); let sx_pid = child.id(); - // Give sandbox-exec → sh → sleep chain time to come up. + // Give the launcher → sh → sleep chain time to come up. thread::sleep(Duration::from_millis(800)); let before = count_processes(&pattern); assert!( @@ -188,7 +204,7 @@ fn test_sigkill_to_sx_orphans_subtree_known_limitation() { } let _ = child.wait(); - // Brief settle; orphan is reparented to launchd but stays alive. + // Brief settle; the orphan is reparented to init but stays alive. thread::sleep(Duration::from_millis(500)); let after_kill = count_processes(&pattern);