feat(linux): add Landlock sandbox backend with macOS feature parity - #50
Open
Pierozi wants to merge 12 commits into
Open
feat(linux): add Landlock sandbox backend with macOS feature parity#50Pierozi wants to merge 12 commits into
Pierozi wants to merge 12 commits into
Conversation
`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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
[platform.macos.*]/[platform.linux.*]sectionsTesting
linux_sandbox.rsintegration tests covering filesystem, network, and env isolationscripts/test-security.sh) runs on both platformssx -- <command>works on Linux with filesystem and network constraints as expectedsx --dry-run,--explain,--trace(macOS only) work correctly on each platform