Skip to content

feat(linux): add Landlock sandbox backend with macOS feature parity - #50

Open
Pierozi wants to merge 12 commits into
mainfrom
feat/linux
Open

feat(linux): add Landlock sandbox backend with macOS feature parity#50
Pierozi wants to merge 12 commits into
mainfrom
feat/linux

Conversation

@Pierozi

@Pierozi Pierozi commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Implement Linux sandbox backend using Landlock LSM (kernel 5.13+, ABI 3+)
  • Support deny-by-default filesystem rules with hierarchy-aware allow-list emulation
  • Network isolation via user+network namespaces with seccomp fallback
  • Platform-specific profile overlays via [platform.macos.*] / [platform.linux.*] sections
  • Extend CI/CD to test and build on both macOS and Linux
  • Update documentation with platform differences and per-OS configuration guidance
  • Refuse to run if Landlock enforcement fails (no silent degradation)

Testing

  • QA workflow tests on both macOS (Intel, Apple Silicon) and Linux (x86_64, arm64)
  • Release builds for all four targets; cross-compile arm64-linux from x86_64
  • New linux_sandbox.rs integration tests covering filesystem, network, and env isolation
  • Security verification script (scripts/test-security.sh) runs on both platforms
  • Existing unit and integration tests pass on Linux
  • Manual verification: sx -- <command> works on Linux with filesystem and network constraints as expected
  • sx --dry-run, --explain, --trace (macOS only) work correctly on each platform

Pierozi and others added 12 commits August 23, 2026 00:16
`sx` now runs on Linux with the same CLI, config format and profiles as on
macOS. The config/profile/CLI layers were already platform-neutral; they now
produce a `SandboxParams` that a platform backend turns into a launcher:
`sandbox-exec -f <profile>` on macOS, `sx --sandbox-apply <spec>` (a re-exec of
this binary) on Linux.

Enforcement on Linux:
- Filesystem via Landlock, requested at ABI 3 with best-effort downgrade.
  `IoctlDev` is left unhandled so terminal control keeps working, mirroring
  Seatbelt's global `(allow file-ioctl)`. Signal scoping is requested to match
  `(allow signal (target self))`.
- `deny_read` is emulated by subtraction: Landlock is allow-list only, so an
  allowed hierarchy containing a denied path is expanded into its siblings.
- Network isolation via a user+network namespace, falling back to a seccomp
  filter where unprivileged user namespaces are blocked. `localhost` gives the
  sandbox its own private loopback.
- Fails closed: if Landlock is not enforced, or no network mechanism can be
  applied, sx refuses to run rather than execute unsandboxed.

macOS is unchanged. Both backends compile on every target so the Seatbelt path
stays type-checked and unit-tested by Linux CI, and the generated profile is
identical: profile path sets were split into `[platform.*]` overlays without
altering what macOS resolves.

Also:
- Profiles gain `[platform.macos.*]` / `[platform.linux.*]` overlays
- `--trace` reports that it is unavailable on Linux instead of silently no-op'ing
- `--dry-run` renders the resolved Landlock policy, including denied paths
- Default shell falls back to /bin/bash on Linux, /bin/zsh on macOS
- Expanded paths are deduplicated (usr-merge collapses /bin, /sbin, /lib)
- The Linux spec file omits env values so configured secrets never hit /tmp
- QA and release workflows build and test on both platforms
- scripts/test-security.sh is cross-platform and now asserts real enforcement
  behaviour, not just policy text; fixes a `set -e` abort and a broken relative
  binary path in it

Tests: 298 passing, including 17 end-to-end Linux sandbox tests driven through
the real binary, and signal-forwarding tests now running on Linux too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Git treats a permission error on its user config as fatal, so `sx -- git
status` failed outright rather than falling back to defaults. This was
Linux-only because XDG is the norm there, but macOS has the same failure for
anyone with a ~/.gitconfig, so the paths belong in the shared base profile.

Read-only, and not where git keeps credentials: ~/.git-credentials stays
denied by deny-by-default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cache directories are readable but not writable by design on both platforms, so
a fresh install greets you with a permission error from starship (or any other
prompt that keeps a per-session log) before you have a global config. That is
the sandbox working, but the error does not say so.

Document the specific paths a prompt and version manager need, with XDG and
macOS variants, and note why granting all of ~/.cache is the wrong shortcut:
caches are places tools later execute from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
anyhow 1.0.102 violates borrow rules in `Error::downcast_mut()` when called on
an error that had context added, which is undefined behaviour. Both the
cargo-audit and cargo-deny CI gates flag it.

Lockfile-only bump to 1.0.104; the `anyhow = "1"` requirement is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… closed

Two ways deny_read could be defeated on Linux.

A symlink inside an allowed directory defeated it entirely. Landlock registers
a rule against the inode a path resolves to, and the carve-out emitted a rule
per directory entry using the entry's own name. A link whose name sits outside
the denied subtree therefore handed that subtree straight back:

    ~/shortcut -> ~/private     with deny_read = ["~/private"]
    cat ~/private/key           -> TOPSECRET

Entries are now resolved before any decision is made, and a link whose target
still contains a denied path is carved rather than granted. Resolving means
cycles are reachable, so expansion tracks visited directories.

A deny_read glob that matched nothing yet failed open. Patterns resolve once at
policy-build time, so `deny_read = ["~/secret*"]` with no current match left
`~` granted as a whole and any file created afterwards was readable. A
directory that a deny pattern could match in is now always expanded entry by
entry, which leaves later files outside every rule.

Neither path is reachable with the default profile, whose denies sit outside
the allowed hierarchies - they need a config that allows a parent of a deny.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Linux policy travelled to the launcher as a file under /tmp, which the
sandbox grants write access to. A concurrent sandboxed process could watch for
the file and swap it between the moment sx writes it and the moment the
launcher reads it, choosing the policy for the next run. The policy is now set
in the child's environment at execve time, which has no such window; the
launcher clears the variable before exec so the sandboxed program never sees
it, and oversized policies are rejected up front instead of failing with E2BIG.
This also removes the temp file that leaked whenever sx was SIGKILLed.

Dynamic-loader variables were only filtered on macOS (DYLD_*). On Linux LD_*
reached the launcher, and a preloaded library runs before main - early enough
to stop the policy being applied at all. pass_env normally filters them as an
allow-list, but it is empty exactly when a project sets inherit_base = false.
Both prefixes are now dropped unconditionally on both platforms and cannot be
re-added through set_env.

The launcher also re-execs /proc/self/exe rather than current_exe(), so it
always gets the running image even if the binary was replaced underneath it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The seccomp fallback filtered socket(2), but io_uring can open and connect
sockets through submission queue entries that never pass through a syscall the
filter can see. io_uring_setup is now refused with ENOSYS, which programs treat
as "no io_uring here" and fall back from. Only the fallback path is affected;
the namespace path has no interfaces to reach in the first place.

Failing to configure a namespace was also treated as "namespace unavailable"
and fell through to seccomp. But unshare cannot be undone: the process would
continue in a half-built namespace with unmapped credentials, where every file
access fails for reasons that have nothing to do with the sandbox. That case is
now distinguished from a refused unshare and fails immediately with a message
naming the real cause.

Also pins the size of the hand-rolled ifreq at compile time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The working directory gets full access, and that grant lands after the deny
rules on both backends, so a deny inside it has no effect. That is intentional
- a project under ~/Documents still has to build - but it means `cd ~ && sx`
silently voids every deny, including the ~/.ssh and ~/.aws protection the
README leads with.

sx now warns when this happens and `--explain` marks the affected entries as
overridden. Behaviour is unchanged; it just stops failing silently.

Documents the same in SECURITY.md, along with two things worth stating plainly:
.sandbox.toml is trusted input living in a directory the sandbox can write to,
and a symlink inside an allowed directory pointing outside it is not reachable,
because Landlock matches on the resolved target.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`cargo clippy -- -D warnings` only covers the crate, so lints in tests never
gated anything. Switches the QA job to --all-targets and clears the three
warnings that were already there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI on ubuntu-latest showed that creating a user namespace and being allowed to
configure it are different permissions: `unshare` succeeded, then writing
/proc/self/setgroups was refused with EPERM. `unshare` cannot be undone, so the
process was stranded in a namespace where its own credentials are unmapped and
every file access fails for reasons unrelated to the sandbox.

The whole sequence now runs first in a throwaway child. `sx` only commits to a
namespace once that rehearsal succeeds, and otherwise takes the seccomp
fallback it already had. The half-built state is now unreachable rather than
merely reported, and `--dry-run` says which mechanism will actually be used.

Verified both paths end to end: the namespace path leaves only loopback, and
the forced fallback blocks socket(AF_INET) with EAFNOSUPPORT while keeping the
filesystem policy and no_new_privs intact. localhost, which needs a real
namespace, refuses with an actionable message instead of half-working.

Also gates two Linux-only constants that broke the macOS lint, and fixes a
hint string whose line continuations had been folded into literal whitespace.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`OsStr` is only used by the Linux spec helper, so `clippy --all-targets` on
macOS rejected it as unused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The network path depends on whether the machine can use a user namespace, so a
green run alone does not say what was covered. Print the kernel ABI, the chosen
network mechanism and the setuid policy up front. GitHub's Linux runners take
the seccomp fallback; a developer machine with working user namespaces takes
the namespace path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant