Skip to content

fix(sandbox): Landlock FS bound plus workspace_only defense-in-depth - #89

Merged
adriannoes merged 4 commits into
developmentfrom
fix/bugbot-batch-sandbox-landlock
Sep 22, 2026
Merged

adriannoes merged 4 commits into
developmentfrom
fix/bugbot-batch-sandbox-landlock

Conversation

@adriannoes

Copy link
Copy Markdown
Collaborator

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_only stays defense-in-depth. The #87 encoding cat-and-mouse (\x2f, identity PW\D=, quote-split file:, …) is frozen — that loop does not converge and is why Cloud never merged.

Allowlist DiD (original #66, 68a, #82)

  • Walk the first existing ancestor when realpath fails (workspace/../../tmp/stolen).
  • Collapse . / .. lexically so a missing directory before .. cannot pin the walk at the workspace; do not cancel .. across a symlink.
  • Scan quoted/embedded / ~ and relative ../ on the full command.
  • Extract file: URLs (including percent-decoding). https:// stays allowed.
  • Expand $HOME / $PWD on the full command, including glued $IFS and mid-token $HOME. Other $ forms fail closed.
  • Fail closed on in-command HOME= / PWD= assignment, export, and unset.
  • Resolve bare relative tokens (cat leak) against the workspace.
  • strdup OOM is fail-closed.

Kernel bound (original #74, #76) plus #88 Must Fix

  • Probe Landlock ABI; ABI-1 attr size so older kernels are not E2BIG.
  • RW under the workspace; RO/exec for /bin /usr /lib*; writable /dev/null /dev/zero /dev/urandom /dev/tty.
  • Privileged unshare(CLONE_NEWNS|NEWNET|NEWPID) first; on failure enter user ns then unshare again. Still failing → isolation error (fail-closed).
  • Fork after CLONE_NEWPID so the command is PID 1 (/bin/echo A; /bin/echo B can still fork).
  • Isolation failure uses a control pipe, not sh exit 122/123.
  • MS_REC|MS_PRIVATE after CLONE_NEWNS. PR_SET_NO_NEW_PRIVS fails closed.
  • No .github/workflows/ci.yml AppArmor 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 on development are preserved. Landlock does not grant /dev/nv*.

Cloud failure (do not repeat)

Test plan

  • CI=true make clean && CI=true make test
  • make static
  • make test_allowlist / make test_sandbox (macOS: Landlock/userns tests skip; allowlist string tests run)
  • Linux CI: Landlock + netns + PID-ns multi-command + exit 122/123 are not isolation failures

Residuals

  • Allowlist is not a language interpreter: chr(47)+ has no path character (Landlock is the bound).
  • Encoded-slash / identity-escape / quote-split file: / printf -v / nameref cat-and-mouse is frozen.
  • Conservative scanner FPs: awk '/foo/', echo HOME=foo.
  • Empty workspace_path skips Landlock (namespaces still apply).
  • No pivot_root / minimal /dev.
  • cgroup setup remains non-fatal when the hierarchy is not writable.

Supersedes

Supersedes #87 #88.

Original Bugbot / split PRs covered here: #66, 68a (quoted-path half of #68; 68b bind gateway.host already on development), #82, #74, #76.

Does not mix file/cron/config clusters.

Operator merges.

@adriannoes adriannoes self-assigned this Sep 14, 2026
cursor[bot]
cursor Bot previously requested changes Sep 14, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. Timeout does not kill the sandboxed command (src/sandbox/sandbox.c around the second fork, and reap_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: after sandbox_exec("sleep 60", …, 300, NULL), sh -c sleep 60 was still running with NSpid … 1 and PPid 1. Same leak from test_timeout_kills_process itself. Fix with prctl(PR_SET_PDEATHSIG, SIGKILL) in the command child before execl, or clone(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 contains timed out.

Should Fix

  • Whole /proc grant without remounting procfs (src/sandbox/sandbox_landlock.c RO_PATHS). ls /proc showed host PIDs; cat /proc/<host-pid>/cmdline succeeded under Landlock. /proc/1/root/etc/passwd stayed EACCES, so this is a process leak, not a full FS bypass. Remount proc after PID 1, then grant /proc or /proc/self.
  • docs/SECURITY.md GPU section still says sandbox_exec() does not call mount(). The child now does mount(…, MS_REC|MS_PRIVATE, …).
  • rw_file for /dev/null omits LANDLOCK_ACCESS_FS_TRUNCATE while ABI 3+ handles that bit. echo hi >/dev/null worked on this ABI 6 kernel (char device), but the UAPI says O_TRUNC needs TRUNCATE.
  • landlock_handled_fs stops at ABI 3 FS bits. This kernel reports ABI 6, so later rights (including IOCTL_DEV) stay unhandled and therefore allowed. Probe and handle them even if the distro headers lack the define.
  • No closefrom after fork. /proc/self/fd showed inherited sockets. Landlock does not restrict already-open fds.
  • read(errpipe[0]) has no timeout; a stuck unshare hangs the caller.
  • test_shadow_not_accessible uses cfg == NULL (no Landlock) and only checks strlen(out) > 0.

Nice to Have

  • awk '{print $1}' fail-closes on $1 (same conservative class as documented awk '/foo/').
  • An https URL whose path contains file: can hit the file: scanner (https://example.com/file:foo).
  • Freeze further allowlist encoding growth, as the PR body already says.

Positive Highlights

  • Control pipe instead of sh exit 122/123, with tests for those exits.
  • /bin/echo A; /bin/echo B runs (the #88 Cannot fork bug).
  • Glued $IFS, mid-token $HOME, percent-decoded file:, missing-dir .., and relative symlink cat leak tests are real containment checks, not coverage padding.
  • Split allowlist_path.c / sandbox_landlock.c keeps allowlist.c at 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.

Open in Web View Automation 

Sent by Cursor Automation: Adrianno’s personal code review

Comment thread src/sandbox/sandbox.c
Comment thread tests/test_sandbox.c
Comment thread src/sandbox/sandbox_landlock.c

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale comment

Comment thread src/sandbox/sandbox.c Outdated
adriannoes added a commit that referenced this pull request Sep 14, 2026
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
adriannoes added a commit that referenced this pull request Sep 14, 2026
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
adriannoes added a commit that referenced this pull request Sep 14, 2026
sandbox_exec already remounts the mount tree private and proc after
PID 1; the GPU section still claimed it never called mount().

Refs: #89
adriannoes added a commit that referenced this pull request Sep 14, 2026
GCC -Werror=format-truncation fails CI when snprintf writes
/proc/%s/cmdline into 64 bytes from dirent d_name (up to 255).

Refs: #89
adriannoes added a commit that referenced this pull request Sep 14, 2026
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

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/ssl re-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.

Open in Web View Automation 

Sent by Cursor Security Agent: Security Reviewer

Comment thread src/sandbox/sandbox.c
Comment thread src/sandbox/sandbox_landlock.c Outdated
adriannoes added a commit that referenced this pull request Sep 22, 2026
Coverage stayed at 79.7% (4410/5536) because $HOME/$PWD paths that
remain inside the workspace never took the scanner allow path.

Refs: #89
adriannoes added a commit that referenced this pull request Sep 22, 2026
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

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security review of this head found one remaining medium issue: reserved runtime-state filenames are no longer whole-command substring-blocked on the sandbox-on path, so wrapper commands can still read them when they live in the workspace.

Open in Web View Automation 

Sent by Cursor Security Agent: Security Reviewer

Comment thread src/sandbox/allowlist.c
adriannoes added a commit that referenced this pull request Sep 22, 2026
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.
@adriannoes
adriannoes force-pushed the fix/bugbot-batch-sandbox-landlock branch from 7fb9dd3 to ec9ef6b Compare September 22, 2026 20:54
@adriannoes
adriannoes dismissed cursor[bot]’s stale review September 22, 2026 21:04

Dismissing the stale personal review on outdated SHA 88b3686. All review threads are resolved; current head is ec9ef6b.

@adriannoes
adriannoes merged commit 337fb31 into development Sep 22, 2026
1 check passed
@adriannoes
adriannoes deleted the fix/bugbot-batch-sandbox-landlock branch September 22, 2026 21:09
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