Every team running LLM agents with shell access has the same problem: the agent calls a tool, the tool runs code, and nothing stops that code from reading files it shouldn't, calling home to an unexpected endpoint, or doing something worse. The usual answers are Docker (heavy, requires root or a daemon), chroot (bypassable), or just not solving it and hoping the agent behaves.
I wanted a chmod-simple wrapper — one binary, no daemon, no root — backed by actual kernel enforcement. This is what I built.
What it does
bash
xenarch-guard --allow-write ./workspace -- python3 agent.py
agent.py runs able to write only inside ./workspace. Every other path is unreachable. A fixed set of dangerous syscalls is blocked regardless of what paths are granted. The wrapped process cannot lift these restrictions — they're enforced by the kernel, not by the program's good behavior.
Why Landlock and seccomp, not Docker
Docker solves a different problem. It isolates a whole environment — filesystem, network, process namespace — and it requires either root or a daemon holding root. For sandboxing a single tool call inside an already-running agent process, that's the wrong shape. I wanted something the agent runtime could call the same way it calls any subprocess.
Landlock is a Linux kernel security module (5.13+) that lets an unprivileged process restrict its own filesystem access. Seccomp-bpf lets a process install a syscall filter on itself. Neither requires root. Neither requires a daemon. Together they give you two independent enforcement layers: Landlock controls which paths are reachable, seccomp controls which syscalls are allowed — and the two layers don't interfere with each other.
The architecture is one execve(), not a fork: xenarch-guard installs the Landlock ruleset and seccomp filter on its own process, then becomes the wrapped command via execve(). No parent process left holding state the child could reach for.
Three real bugs I hit building this
Missing PR_SET_NO_NEW_PRIVS. Landlock requires prctl(PR_SET_NO_NEW_PRIVS, 1) before landlock_restrict_self() will succeed on a non-root process — without it, landlock_restrict_self returns EPERM and silently does nothing. The sandbox appeared to work (process ran, exited cleanly) but nothing was actually confined. Found it by running the test suite against a path that should have been denied and watching it pass when it shouldn't.
Missing LANDLOCK_ACCESS_FS_EXECUTE in read grants. An --allow-read grant that didn't include execute rights meant dynamically-linked binaries couldn't load their shared libraries — the dynamic linker needs execute permission on the library files, not just read. Fixed by folding LANDLOCK_ACCESS_FS_EXECUTE into every allow_read and allow_write grant.
Test script not granting /bin and /usr/bin. The integration tests ran commands like python3 without granting the interpreter's actual paths under the Landlock policy — so the tests were testing an unconfined run without knowing it. The fix was explicit path grants in the test harness. This one only showed up by reading the policy summary xenarch-guard prints to stderr before each run.
All three are the category of bug that only shows up when you actually run the sandbox against a real workload, not when you read the code.
Two Landlock behaviors that aren't in the documentation
Granted paths must exist when xenarch-guard starts. Landlock builds its ruleset by opening each granted path at startup. If the path doesn't exist yet — because the wrapped command is about to create it — the grant is silently dropped. Grant the parent directory instead.
--allow-write on a device node fails with errno 22. Landlock rejects the rule outright (not the operation — the rule), so the device stays unreachable. /dev/null hits this. Grant /dev instead of /dev/null.
Neither of these produces a hard error. Both are only visible in the policy summary on stderr. I found both while building xenarchos, a skill runner that uses xenarch-guard as a dependency, and the first draft of its README example silently failed because of finding #1.
What it doesn't protect against
The threat model is in docs/THREAT_MODEL.md and I'd rather link it than summarize it badly. Short version: this is not a container. A sufficiently privileged process can escape Landlock via kernel exploits. The seccomp deny-list blocks the syscalls most commonly used to do that, but "impenetrable" isn't a claim this project makes. The honest use case is confining cooperative-but-untrusted code — an LLM-generated tool call that shouldn't have write access to your home directory — not confining an adversary who knows they're being sandboxed.
Result
11/11 integration tests passing on WSL2 (Landlock ABI 7, Linux 6.x). The test suite includes a symlink-escape check and a network confinement test. Ships as a single statically-linkable C++ binary with one dependency (libseccomp).
github.com/xenaarch-dev/xenarch-guard
Top comments (0)