fix(sandbox): Landlock FS bound plus workspace_only defense-in-depth - #89
Conversation
There was a problem hiding this comment.
Summary
This is a focused sandbox rewrite that does what the title claims: Landlock is the kernel host-FS bound, and workspace_only is defense-in-depth. It closes the #87 glued $IFS scanner hole and the #88 isolation bugs (control pipe instead of exit 122/123, fork so the command is PID 1, writable /dev/null). The extra fork after CLONE_NEWPID breaks the timeout contract: the parent SIGKILLs the isolator, and the command (PID 1 in the new namespace) is reparented to host init and keeps running. test_timeout_kills_process still passes because it only looks at the output string.
Repo is a C99 edge agent (namespaces + cgroups + tools/channels/gateway). This PR stays in src/sandbox/ plus tests and SECURITY/ARCHITECTURE notes.
Must Fix
- Timeout does not kill the sandboxed command (
src/sandbox/sandbox.caround the secondfork, andreap_child).unshare(CLONE_NEWPID)does not move the caller; the new child is PID 1. On timeout the parent kills the isolator only. Reproduced here: aftersandbox_exec("sleep 60", …, 300, NULL),sh -c sleep 60was still running withNSpid … 1andPPid 1. Same leak fromtest_timeout_kills_processitself. Fix withprctl(PR_SET_PDEATHSIG, SIGKILL)in the command child beforeexecl, orclone(CLONE_NEWPID|SIGCHLD)so the pid the parent tracks is PID 1 (killing it tears down the namespace). The regression must assert the process is gone, not only that the output containstimed out.
Should Fix
- Whole
/procgrant without remounting procfs (src/sandbox/sandbox_landlock.cRO_PATHS).ls /procshowed host PIDs;cat /proc/<host-pid>/cmdlinesucceeded under Landlock./proc/1/root/etc/passwdstayedEACCES, so this is a process leak, not a full FS bypass. Remountprocafter PID 1, then grant/procor/proc/self. docs/SECURITY.mdGPU section still sayssandbox_exec()does not callmount(). The child now doesmount(…, MS_REC|MS_PRIVATE, …).rw_filefor/dev/nullomitsLANDLOCK_ACCESS_FS_TRUNCATEwhile ABI 3+ handles that bit.echo hi >/dev/nullworked on this ABI 6 kernel (char device), but the UAPI saysO_TRUNCneeds TRUNCATE.landlock_handled_fsstops at ABI 3 FS bits. This kernel reports ABI 6, so later rights (includingIOCTL_DEV) stay unhandled and therefore allowed. Probe and handle them even if the distro headers lack the define.- No
closefromafter fork./proc/self/fdshowed inherited sockets. Landlock does not restrict already-open fds. read(errpipe[0])has no timeout; a stuckunsharehangs the caller.test_shadow_not_accessibleusescfg == NULL(no Landlock) and only checksstrlen(out) > 0.
Nice to Have
awk '{print $1}'fail-closes on$1(same conservative class as documentedawk '/foo/').- An https URL whose path contains
file:can hit thefile:scanner (https://example.com/file:foo). - Freeze further allowlist encoding growth, as the PR body already says.
Positive Highlights
- Control pipe instead of
shexit 122/123, with tests for those exits. /bin/echo A; /bin/echo Bruns (the #88Cannot forkbug).- Glued
$IFS, mid-token$HOME, percent-decodedfile:, missing-dir.., and relative symlinkcat leaktests are real containment checks, not coverage padding. - Split
allowlist_path.c/sandbox_landlock.ckeepsallowlist.cat 686 lines (under the 1000-line rule). - Fail-closed namespaces with user-ns fallback, and no AppArmor sysctl CI hack.
Local evidence: CI=true gcc -Werror test_allowlist and test_sandbox passed (linked -lm; Makefile still passes -lcurl). ASan test_sandbox passed aside from the leftover sleep. Landlock ABI 6. GitHub static, test, release was still pending at review time.
Sent by Cursor Automation: Adrianno’s personal code review
GCC -Werror=unused-result fails the release job: (void)write() does not satisfy warn_unused_result under _FORTIFY_SOURCE. Assign the byte count before _exit so CI=true make release compiles. Refs: #89
The extra fork after CLONE_NEWPID left the command reparented to host init when the parent SIGKILLed the isolator. Set PR_SET_PDEATHSIG, kill the process group, remount procfs in PID 1 before Landlock, close fds >= 3, and poll the isolation pipe so a stuck unshare cannot hang. Refs: #89
sandbox_exec already remounts the mount tree private and proc after PID 1; the GPU section still claimed it never called mount(). Refs: #89
GCC -Werror=format-truncation fails CI when snprintf writes /proc/%s/cmdline into 64 bytes from dirent d_name (up to 255). Refs: #89
GCC -Werror=format-truncation still treats dirent d_name as 255 bytes even after a strlen cap. Size the snprintf destination for that bound. Refs: #89
There was a problem hiding this comment.
Stale comment
Security review of the Landlock + PID-1 sandbox rewrite found two medium issues: cgroup limits still attach to the isolator rather than the command process, and the Landlock RO grant of
/etc/sslre-opens a host-FS subtree that the string scanner cannot see. Prior threads on timeout reaping,/proc, and inherited FDs look addressed on this head.Sent by Cursor Security Agent: Security Reviewer
Coverage stayed at 79.7% (4410/5536) because $HOME/$PWD paths that remain inside the workspace never took the scanner allow path. Refs: #89
tok points into the strdup'd command. deny_unresolved read it after free, which ASan reported as a heap use-after-free on $HOME.extra. Refs: #89
Sandbox-on only matched those basenames as shell tokens, so
python -c open('auth_tokens.json') was allowed while the
sandbox-off fallback already blocked the same string.
Refs: #89
workspace_only tokenized on whitespace, so a quoted path, a file: URL, or $HOME/$PWD could leave the workspace, and strdup failure fell open. Record the unresolved-path reason before freeing the command copy. Substring-block auth_tokens.json, shellclaw.pid, and shellclaw.log on the whole command so python -c open() cannot skip the basename check.
unshare does not move the caller into the new PID namespace, and a shared mount tree would let a later umount of /proc hit the host. Fork so the command is PID 1, require MS_PRIVATE, remount proc, and report setup failure on a control pipe: the child writes the byte, and EOF is success. Join the cgroup before that fork, reap on timeout, and close inherited fds.
The string allowlist cannot see interpreter concatenation such as chr(47)+. Enter the workspace before restrict_self, grant read on /etc/ssl/certs only, and split prepare() from restrict_self so gcov can still write. Landlock setup failure is fail-closed on the isolation pipe.
Namespace setup is fail-closed, the mount tree is made private, and proc is remounted for PID 1. Landlock, not the string allowlist, is the kernel host-FS bound when a workspace path is set.
7fb9dd3 to
ec9ef6b
Compare


Summary
Local rewrite of the sandbox FS cluster that Cloud tried to land as #87 and #88. One PR against
development. Operator merges.Landlock is the kernel host-FS bound.
workspace_onlystays defense-in-depth. The #87 encoding cat-and-mouse (\x2f, identityPW\D=, quote-splitfile:, …) is frozen — that loop does not converge and is why Cloud never merged.Allowlist DiD (original #66, 68a, #82)
realpathfails (workspace/../../tmp/stolen)../..lexically so a missing directory before..cannot pin the walk at the workspace; do not cancel..across a symlink./~and relative../on the full command.file:URLs (including percent-decoding).https://stays allowed.$HOME/$PWDon the full command, including glued$IFSand mid-token$HOME. Other$forms fail closed.HOME=/PWD=assignment,export, andunset.cat leak) against the workspace.strdupOOM is fail-closed.Kernel bound (original #74, #76) plus #88 Must Fix
/bin/usr/lib*; writable/dev/null/dev/zero/dev/urandom/dev/tty.unshare(CLONE_NEWNS|NEWNET|NEWPID)first; on failure enter user ns then unshare again. Still failing → isolation error (fail-closed).CLONE_NEWPIDso the command is PID 1 (/bin/echo A; /bin/echo Bcan still fork).shexit 122/123.MS_REC|MS_PRIVATEafterCLONE_NEWNS.PR_SET_NO_NEW_PRIVSfails closed..github/workflows/ci.ymlAppArmor sysctl hack. Tests skip or treat deny as success when the runner cannot apply userns/Landlock.Jetson GPU (
/dev/nvhost,/dev/nvgpu,/dev/nvmap) and Argus (/tmp/argus_socket) substring blocklist entries ondevelopmentare preserved. Landlock does not grant/dev/nv*.Cloud failure (do not repeat)
CLONE_NEWPIDwithoutfork(), exit 122/123 colliding with isolation sentinels. Cloud-onlyapparmor_restrict_unprivileged_userns=0in CI. Same authorship issue.Test plan
CI=true make clean && CI=true make testmake staticmake test_allowlist/make test_sandbox(macOS: Landlock/userns tests skip; allowlist string tests run)exit 122/123are not isolation failuresResiduals
chr(47)+has no path character (Landlock is the bound).file:/printf -v/ nameref cat-and-mouse is frozen.awk '/foo/',echo HOME=foo.workspace_pathskips Landlock (namespaces still apply).pivot_root/ minimal/dev.Supersedes
Supersedes #87 #88.
Original Bugbot / split PRs covered here: #66, 68a (quoted-path half of #68; 68b bind
gateway.hostalready ondevelopment), #82, #74, #76.Does not mix file/cron/config clusters.
Operator merges.