Skip to content

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

Closed
adriannoes wants to merge 30 commits into
developmentfrom
cursor/sandbox-fs-landlock-d519
Closed

adriannoes wants to merge 30 commits into
developmentfrom
cursor/sandbox-fs-landlock-d519

Conversation

@adriannoes

Copy link
Copy Markdown
Collaborator

Why

workspace_only is a string scanner. While it is the primary host-FS gate (sandbox_exec has no pivot_root), Security review will keep finding sibling encodings (\x24, identity PW\D=, chr(47)+, …). That loop does not converge.

Landlock is the kernel filesystem bound. The scanner stays defense-in-depth. Isolation fails closed if Landlock or mount/net/pid namespaces cannot apply (user namespace first when unprivileged). open(chr(47)+'etc/passwd') stays residual on the scanner; Landlock denies the host inode.

This PR folds:

  1. The rewritten workspace_only allowlist from #87 (cursor/sandbox-fs-allowlist-d519 @ 3f6528f).
  2. Landlock + relative-token defense-in-depth from draft #74, rewritten (not Bugbot history).
  3. Fail-closed namespaces from draft #76, rewritten.

Does not mix file/cron/config clusters. Jetson GPU (/dev/nvhost, /dev/nvgpu, /dev/nvmap) and Argus (/tmp/argus_socket) substring blocklist entries already on development are preserved.

Kernel bound (sandbox_exec)

  • Probe Landlock ABI; pass only bits the kernel understands (ABI-1 attr size so older kernels are not E2BIG).
  • When workspace_path is set: RW under the workspace, RO/exec for /bin /usr /lib* plus a small /dev and loader set. Missing optional RO paths are skipped; workspace rule + restrict_self fail closed (_exit(122) → sandbox_exec returns -1).
  • Privileged unshare(CLONE_NEWNS|NEWNET|NEWPID) first; on failure enter user namespace (setgroups/uid_map/gid_map) then unshare again. Still failing → _exit(123) → -1.
  • PR_SET_NO_NEW_PRIVS before Landlock. cgroups v2 stay best-effort.

Scanner (defense-in-depth)

Unchanged intent from #87: quoted/embedded paths, $HOME/$PWD (including glued $IFS and encoded $), file: (quote-split, hex/unicode/octal/identity), lexical .., HOME/PWD mutation (declare -n, exec -c, printf -v, env -i, …).

Added from #74's intent: resolve bare relative tokens (cat leak) against the workspace so a symlink to /etc/passwd is denied before exec.

Test plan

  • make test_allowlist CC=gcc — includes test_relative_symlink_indirection (cat leak) plus prior fix(sandbox): harden workspace_only host-FS allowlist #87 cases
  • make test_sandbox CC=gcc — Landlock symlink + chr(47)+ host read blocked; workspace write allowed; host loopback hidden by netns
  • make static CC=gcc
  • CI=true GATEWAY=1 make test CC=gcc

Residuals

  • Allowlist is still not a language interpreter: chr(47)+ has no path character in the command text (Landlock is the bound).
  • Conservative scanner FPs: awk '/foo/', echo HOME=foo, real https://host/foo/../bar.
  • No pivot_root / minimal /dev; Landlock does not grant /dev/nv*.
  • cgroup setup remains non-fatal when the hierarchy is not writable.

Supersedes #87, #74, and #76.

adriannoes and others added 28 commits September 14, 2026 14:24
Lexical prefix matching treats workspace/../../tmp/newfile as inside
the workspace whenever realpath fails. Lock the missing-file ancestor
case before walking dirname like tools/file.c.

Co-authored-by: Adrianno E. S. <adriannoes@users.noreply.github.com>
realpath cannot canonicalize a missing destination, so a lexical
workspace prefix allowed workspace/../../tmp/stolen. Collapse ..
through existing dirs like tools/file.c. sandbox_exec does not
pivot_root, so this is the host FS gate.

Co-authored-by: Adrianno E. S. <adriannoes@users.noreply.github.com>
Whitespace tokenization never saw cat '/etc/passwd' or a path inside
python3 -c. Namespaces do not chroot, so lock those host-FS bypasses
and keep relative/URL slashes allowed.

Co-authored-by: Adrianno E. S. <adriannoes@users.noreply.github.com>
Whitespace tokens never saw cat '/etc/passwd' or python3 -c open().
Scan the full command for / and ~ fragments, strip one quote layer,
and fail closed on strdup OOM. Relative 3/4 and https:// stay allowed;
file:/// still blocks.

Co-authored-by: Adrianno E. S. <adriannoes@users.noreply.github.com>
has_path_chars only flagged / ~ ., so cat $HOME/.shellclaw/... skipped
the workspace gate. Lock HOME, ${HOME}, $PWD, ANSI-C $'\\x2f...', and
quoted \"$HOME/...\" before expanding those tokens.

Co-authored-by: Adrianno E. S. <adriannoes@users.noreply.github.com>
has_path_chars ignored tokens that only become absolute after /bin/sh
expands them. Expand HOME/PWD (and one quote layer), fail closed on
ANSI-C and other \$ forms, and do not treat \${HOME}/ as a new FS root.

Co-authored-by: Adrianno E. S. <adriannoes@users.noreply.github.com>
Whitespace $HOME tests never saw cat$IFS/etc/passwd, cat${IFS}/...,
ANSI-C cat$'\x20/...', python3 -c open('$HOME/...'), or cat"$HOME/...".
Lock those host-FS bypasses and expand $PWD to the process PWD before
asserting .. collapse.

Co-authored-by: Adrianno E. S. <adriannoes@users.noreply.github.com>
has_path_chars only ran when a strtok token started with $. Shell glues
$IFS, ANSI-C $'...', and "$HOME" onto the previous word. Expand HOME/PWD
(plus a following / suffix) anywhere in the command, fail closed on other
$ forms, and treat / after } as a new FS root unless it closes ${HOME} or
${PWD}. Unset HOME no longer maps ~/x to /x. Copy dirname into a second
buffer so the ancestor walk is not snprintf overlap.

Co-authored-by: Adrianno E. S. <adriannoes@users.noreply.github.com>
Task 7.1 residual now names the full-command $ scan (including glued
$IFS and mid-token $HOME) and the leftovers: relative cd tokens,
Python chr(47), and awk '/foo/' false positives. Landlock is next.

Co-authored-by: Adrianno E. S. <adriannoes@users.noreply.github.com>
The ancestor walk stopped at the workspace for /ws/nope/../../../tmp,
file:/ and file://localhost/ skipped the / scanner, and python3 -c
open('../secret') never started a path fragment. Lock those host-FS
bypasses before collapsing .. lexically.

Co-authored-by: Adrianno E. S. <adriannoes@users.noreply.github.com>
A missing directory before .. stopped the ancestor walk at the workspace.
Collapse . and .. first so /ws/nope/../../../tmp leaves the sandbox.
Extract file: URL paths (file:/, file://localhost/, file://etc/passwd)
and join embedded relative ../ to the workspace before the same check.

Co-authored-by: Adrianno E. S. <adriannoes@users.noreply.github.com>
cppcheck uninitvar on parts[i] when the input is only '/'. Initialize
the stack array and skip a NULL slot before strlen.

Co-authored-by: Adrianno E. S. <adriannoes@users.noreply.github.com>
Lock four Security Agent HIGH residuals: interpreter \x2f/\57/\u002f
leading slashes, in-command HOME/PWD assignment, symlink-then-.., and
percent-encoded file: URLs. Assertions expect deny.

Co-authored-by: Adrianno E. S. <adriannoes@users.noreply.github.com>
Reconstruct \x2f / \57 / \u002f as a leading slash plus path body.
Fail closed when the command assigns, exports, or unsets HOME/PWD.
realpath the original path before lexical collapse, and refuse to
cancel .. across a symlink. Percent-decode file: URLs before the
workspace check. Keep https:// allowed.

Co-authored-by: Adrianno E. S. <adriannoes@users.noreply.github.com>
Move val and n into the octal branch so variableScope is clean.

Co-authored-by: Adrianno E. S. <adriannoes@users.noreply.github.com>
Lock three Security Agent HIGH residuals: eval/sh -c quoted HOME/PWD
mutation, quote-split file: schemes, and encoded . / \u{2f} / \N{.
Assertions expect deny.

Co-authored-by: Adrianno E. S. <adriannoes@users.noreply.github.com>
Strip quotes and trivial quote-concat before HOME/PWD mutation and
file: scans so eval/sh -c and f'ile:// cannot skip the gate.
Reconstruct encoded . and / including \\u{2f}; fail-closed on \\N{.
Keep https:// allowed. Not a Python interpreter for chr(47)+.

Co-authored-by: Adrianno E. S. <adriannoes@users.noreply.github.com>
Move lo into the \\x branch so variableScope is clean.

Co-authored-by: Adrianno E. S. <adriannoes@users.noreply.github.com>
Lock three Security Agent HIGH residuals: https://.../../ containment,
Perl braced hex, and HOME/PWD via env -i / os.environ.pop / comma.
Assertions expect deny.

Co-authored-by: Adrianno E. S. <adriannoes@users.noreply.github.com>
Scan ../ after :// so URL-disguised walks are containment-checked;
real https:// fetches without a .. walk stay allowed. Reconstruct
Perl \x{2f}/\x{2e} like \u{2f}. Fail closed on env -i, env -u HOME|PWD,
os.environ.pop/del, and HOME=/PWD= after a comma. Not chr(47)+.

Co-authored-by: Adrianno E. S. <adriannoes@users.noreply.github.com>
Lock three Security Agent HIGH residuals: POSIX read HOME/PWD,
GNU env --unset and clustered -iu, os.unsetenv/putenv/clear,
Perl braced octal, and \x66/\u0066 hiding file:. Assertions
expect deny. https:// stays allowed. Not chr(47)+.

Co-authored-by: Adrianno E. S. <adriannoes@users.noreply.github.com>
Fail closed on POSIX read HOME/PWD, GNU env --unset and clustered
-iu, and os.unsetenv/putenv/environ.clear. Reconstruct Perl \o{57}
like \x{2f}. Decode \xNN/\u00NN so \x66ile: cannot hide file:.
Keep https:// allowed. Not chr(47)+.

Co-authored-by: Adrianno E. S. <adriannoes@users.noreply.github.com>
Lock three Security Agent HIGH residuals: octal \146/\072 and
f\ile: scheme hiding, POSIX backslash-newline continuation, and
printf -v / os.environ[] / .update HOME/PWD. Assertions expect
deny. https:// stays allowed. Not chr(47)+.

Co-authored-by: Adrianno E. S. <adriannoes@users.noreply.github.com>
Decode octal scheme bytes and fold shell identity escapes so
\146ile: / f\ile: hit the file: scan. Collapse POSIX backslash
+ newline before HOME/PWD and file: gates. Fail closed on
printf -v HOME|PWD, os.environ[] assign, and .update.
Keep https:// allowed. Not chr(47)+.

Co-authored-by: Adrianno E. S. <adriannoes@users.noreply.github.com>
Assert deny for identity-escaped PWD/HOME keywords, bash
declare -n targeting PWD, exec -c, and \x24/\044/\u0024
before getenv. Keep https:// allowed. Not chr(47)+.

Co-authored-by: Adrianno E. S. <adriannoes@users.noreply.github.com>
Run the same hex/unicode/octal/identity decode as file: recovery
before the HOME/PWD keyword gate and the $ expansion scan so
PW\D=, \unset, \env -i, \x24, \044, and \u0024 cannot skip getenv.
Fail closed on declare -n targeting HOME|PWD and exec -c.
Keep https:// allowed. Not chr(47)+.

Co-authored-by: Adrianno E. S. <adriannoes@users.noreply.github.com>
Assert deny for bare relative symlink names (cat leak), Landlock
workspace reads of host /etc/passwd (symlink and chr(47)+),
workspace writes still allowed, and host loopback hidden by netns.
Not more scanner encodings.

Co-authored-by: Adrianno E. S. <adriannoes@users.noreply.github.com>
Apply Landlock to the configured workspace (ABI-probed, fail-closed)
so symlink and chr(47)+ host reads cannot skip the string scanner.
Enter a user namespace when needed, then unshare mount/net/pid;
isolation failure returns -1. Resolve bare relative tokens as
defense-in-depth. Keep Jetson GPU/Argus blocklist. Scanner stays
defense-in-depth, not a language interpreter.

Co-authored-by: Adrianno E. S. <adriannoes@users.noreply.github.com>
@adriannoes
adriannoes marked this pull request as ready for review September 14, 2026 14:33
AppArmor blocks unshare(CLONE_NEWUSER) in unsigned test binaries,
so sandbox_exec fail-closes before echo/Landlock tests can run.
Disable the restriction in CI only. Not a product bypass.

Co-authored-by: Adrianno E. S. <adriannoes@users.noreply.github.com>

@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

Not approved: Cursor Security Agent was still pending after the 8 minute wait, so this head cannot be approved on automated-review signals. Cursor Bugbot was not present after the first poll and was skipped. Human review is needed; adriannoes was assigned as reviewer.

Open in Web View Automation 

Sent by Cursor Approval Agent: Pull Request Router and Approver

GitHub-hosted runners often cannot unshare a user namespace or apply
Landlock. sandbox_exec already fail-closes with -1; success-path tests
treated that as a product bug. Skip those cases and treat host-FS/netns
checks as deny. Production isolation stays fail-closed.

Co-authored-by: Adrianno E. S. <adriannoes@users.noreply.github.com>
@adriannoes adriannoes closed this 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

ShellClaw is a C99 edge agent (Jetson / Raspberry Pi) with a Linux namespace sandbox around the shell tool. This PR correctly stops treating the string scanner as the host-FS gate: Landlock is the kernel bound when workspace_path is set, namespaces fail closed (user ns when unprivileged), and the #87 glued $IFS / mid-token $HOME holes are closed in allowlist.c. I compiled test_allowlist and test_sandbox with gcc -Werror (linked -lm only) and both passed. A follow-up probe against the same sandbox.o shows two merge blockers: PID-namespace setup breaks multi-command sh -c, and exit statuses 122/123 are treated as isolation failure.

Must Fix

  1. CLONE_NEWPID without fork() (src/sandbox/sandbox.c isolate_or_exit / setup_child_process). unshare(CLONE_NEWPID) does not move the caller into the new namespace; the next child becomes PID 1. This PR makes that unshare succeed for unprivileged processes via enter_user_namespace(), so the old fail-open path (unshare failed, sh could fork) is gone. Probed: /bin/echo A; /bin/echo B prints A then sh: 1: Cannot fork; /bin/true && /bin/echo OK fails the same way; a pipeline is Killed. After isolate, fork so the command process is PID 1; the intermediate process must waitpid and _exit with that status.
  2. Exit 122/123 collide with isolation sentinels (SANDBOX_EXIT_NO_LL / NO_NS at sandbox.c:44-45, interpreted at sandbox.c:468-482). exit 123 with Landlock applied returns -1 namespace isolation failed. exit 122 with cfg == NULL still reports Landlock failure. Isolation _exit must not share the status space with sh -c.

Should Fix

  • /dev/null and /dev/zero are not writable. ro_file is EXECUTE|READ_FILE only (sandbox.c:323-324). echo hi >/dev/null is cannot create /dev/null: Permission denied. Grant LANDLOCK_ACCESS_FS_WRITE_FILE on those nodes (and likely /dev/tty / /dev/urandom).
  • Granting all of /proc (RO_PATHS includes /proc). Prefer becoming PID 1, remounting proc, and adding /proc/self rather than the host proc tree.
  • No MS_REC|MS_PRIVATE after CLONE_NEWNS. User ns plus a shared mount tree is the usual follow-up; this is not pivot_root.
  • allowlist.c is 1565 lines (command_mutates_home_or_pwd alone is ~260). The 1000-line rule is a presumptive blocker; the PR's own rationale is that the scanner loop does not converge. Freeze new encodings; split later. Landlock is the bound.
  • test_sandbox never runs two external commands, so it cannot fail on Cannot fork. test_workspace_landlock_blocks_abs_etc also passes if python3 is missing (only asserts no root:x:).
  • docs/ARCHITECTURE.md Sandbox section (lines 137-143) still describes unshare-only, no Landlock. Only the module-map row was updated.
  • Audit table 7.1 still lists the substring blocklist as the GPU mitigation (docs/SECURITY.md:26). Landlock does not grant /dev/nv*; globs are not a residual when the ruleset is applied.
  • Empty workspace_path skips Landlock (landlock_restrict_to_workspace returns 0). Namespaces still apply, host FS is open. If sandbox is on, fail closed unless a workspace directory exists.
  • Inherited fds are not Landlock-restricted. Close extra fds after dup2 (closefrom).

Nice to Have

  • This kernel reports Landlock ABI 6. landlock_ruleset_attr is passed at ABI-1 size, so LANDLOCK_SCOPE_SIGNAL and LANDLOCK_ACCESS_FS_IOCTL_DEV are not handled. Worth adding when the headers allow it.
  • prctl(PR_SET_NO_NEW_PRIVS) return is ignored; fail closed if it fails and you are not in a user ns with CAP_SYS_ADMIN.
  • config.example.toml [sandbox] could say that Landlock uses workspace_path (default ~/.shellclaw).

Positive Highlights

  • Landlock as the kernel FS bound is the right end to the #87 encoding loop. Probed: cat /etc/passwd, /proc/self/root/etc/passwd, and python3 open(chr(47)+"etc/passwd") all get Permission denied with a workspace set; /etc/passwd is readable when cfg is NULL (no Landlock), which matches the claimed model.
  • ABI probe plus ABI-1 create_ruleset size is the correct compatibility approach. Missing RO paths are skipped; workspace + restrict_self fail closed.
  • #87 Must Fix is actually locked: test_workspace_only_blocks_glued_shell_expansions covers cat$IFS/etc/passwd, cat${IFS}/..., ANSI-C, and mid-token $HOME. test_relative_symlink_indirection plus the Landlock cat leak / chr(47)+ tests match the new split (scanner DiD, kernel bound for interpreter concat).
  • Netns loopback test is a real isolation check, not a strlen > 0 placeholder.

🤖 ✨ Landlock is the right bound; fork after NEWPID and stop using 122/123 as isolation sentinels before merge.

Open in Web View Automation 

Sent by Cursor Automation: Adrianno’s personal code review

Comment thread src/sandbox/sandbox.c
Comment on lines +183 to +188
static void isolate_or_exit(void)
{
if (unshare_isolation_namespaces() == 0) return;
if (enter_user_namespace() != 0) _exit(SANDBOX_EXIT_NO_NS);
if (unshare_isolation_namespaces() != 0) _exit(SANDBOX_EXIT_NO_NS);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Must Fix: unshare(CLONE_NEWPID) then execl("/bin/sh", ...) without a following fork() does not put the shell in the new PID namespace as PID 1 (man 2 unshare). The first external command becomes init of that namespace; when it exits, later fork()s from sh -c fail.

This is a regression for the stock unprivileged agent. Previously unshare failed and the command ran without NEWPID. enter_user_namespace() makes NEWPID succeed, so real shell commands break.

Probed against this binary (workspace set, Landlock applied):

  • /bin/echo A; /bin/echo B → A then sh: 1: Cannot fork
  • /bin/true && /bin/echo OK → Cannot fork
  • /bin/echo hello | /usr/bin/wc -c → Killed

test_sandbox still passes because the new Landlock cases are one external command or a builtin redirect (echo landlock_ok > wrote.txt).

After isolate_or_exit(), fork: the child is PID 1, the intermediate waitpids and _exits with that status so the agent's original child still reaps correctly. Then remount proc if you want PID isolation to be visible.

Comment thread src/sandbox/sandbox.c
Comment on lines +468 to +482
if (!timed_out && WIFEXITED(child_st)) {
exit_st = WEXITSTATUS(child_st);
if (exit_st == SANDBOX_EXIT_NO_NS || exit_st == SANDBOX_EXIT_NO_LL)
isolation_failed = 1;
}
if (isolation_failed) {
if (exit_st == SANDBOX_EXIT_NO_LL)
snprintf(out, out_cap, "sandbox: Landlock filesystem bound failed");
else
snprintf(out, out_cap, "sandbox: namespace isolation failed");
}
if (used_cgroup)
cgroup_remove(cgroup_base, cgroup_name);
if (isolation_failed)
return -1;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Must Fix: these exit codes collide with a successful sh -c.

SANDBOX_EXIT_NO_LL is 122 and SANDBOX_EXIT_NO_NS is 123. After a successful execl("/bin/sh", ...), WEXITSTATUS is the command's status, not the isolation sentinel. Probed:

  • exit 123 with a valid workspace → sandbox_exec returns -1, sandbox: namespace isolation failed (command ran)
  • exit 122 with cfg == NULL (Landlock never applied) → -1, sandbox: Landlock filesystem bound failed

Do not use the child's exit status as the isolation channel once /bin/sh has started. A pipe byte, CLOEXEC eventfd, or _exit only on the pre-exec paths (and a distinct wait that cannot see sh -c's status) is the usual pattern. 124/125/127 have the same class of collision (chdir / dup2 / exec vs a command that exits those values).

Comment thread tests/test_sandbox.c
fprintf(stderr, "test_workspace_landlock_blocks_symlink_escape: mkdtemp failed\n");
return 1;
}
snprintf(leak_path, sizeof(leak_path), "%s/leak", ws);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Should Fix: this case does not catch the NEWPID-without-fork hole. echo ... > wrote.txt is a dash builtin plus redirection, so sh never fork()s a second process.

Please add a regression that runs two external binaries, for example /bin/echo A; /bin/echo B, with a workspace configured (Landlock on) and assert both lines appear and Cannot fork does not. Same for a pipeline. That is the actual sh -c shape the shell tool will get from the model.

@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.

Not approved: Cursor Security Agent was still pending after the 8 minute wait, so this head cannot be approved on automated-review signals. Cursor Bugbot was not present after the first poll and was skipped. Human review is needed; adriannoes was assigned as reviewer.

Open in Web View Automation 

Sent by Cursor Approval Agent: Pull Request Router and Approver

@adriannoes
adriannoes deleted the cursor/sandbox-fs-landlock-d519 branch September 22, 2026 21:17
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