fix(sandbox): Landlock FS bound plus workspace_only defense-in-depth - #88
adriannoes wants to merge 30 commits into
Conversation
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>
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>
There was a problem hiding this comment.
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.
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>
There was a problem hiding this comment.
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
CLONE_NEWPIDwithoutfork()(src/sandbox/sandbox.cisolate_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 viaenter_user_namespace(), so the old fail-open path (unshare failed,shcould fork) is gone. Probed:/bin/echo A; /bin/echo BprintsAthensh: 1: Cannot fork;/bin/true && /bin/echo OKfails the same way; a pipeline isKilled. After isolate, fork so the command process is PID 1; the intermediate process mustwaitpidand_exitwith that status.- Exit 122/123 collide with isolation sentinels (
SANDBOX_EXIT_NO_LL/NO_NSatsandbox.c:44-45, interpreted atsandbox.c:468-482).exit 123with Landlock applied returns -1namespace isolation failed.exit 122withcfg == NULLstill reports Landlock failure. Isolation_exitmust not share the status space withsh -c.
Should Fix
/dev/nulland/dev/zeroare not writable.ro_fileis EXECUTE|READ_FILE only (sandbox.c:323-324).echo hi >/dev/nulliscannot create /dev/null: Permission denied. GrantLANDLOCK_ACCESS_FS_WRITE_FILEon those nodes (and likely/dev/tty//dev/urandom).- Granting all of
/proc(RO_PATHSincludes/proc). Prefer becoming PID 1, remounting proc, and adding/proc/selfrather than the host proc tree. - No
MS_REC|MS_PRIVATEafterCLONE_NEWNS. User ns plus a shared mount tree is the usual follow-up; this is notpivot_root. allowlist.cis 1565 lines (command_mutates_home_or_pwdalone 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_sandboxnever runs two external commands, so it cannot fail onCannot fork.test_workspace_landlock_blocks_abs_etcalso passes ifpython3is missing (only asserts noroot:x:).docs/ARCHITECTURE.mdSandbox 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_pathskips Landlock (landlock_restrict_to_workspacereturns 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_attris passed at ABI-1 size, soLANDLOCK_SCOPE_SIGNALandLANDLOCK_ACCESS_FS_IOCTL_DEVare 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 withCAP_SYS_ADMIN.config.example.toml[sandbox]could say that Landlock usesworkspace_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, andpython3 open(chr(47)+"etc/passwd")all getPermission deniedwith a workspace set;/etc/passwdis readable whencfgis NULL (no Landlock), which matches the claimed model. - ABI probe plus ABI-1
create_rulesetsize is the correct compatibility approach. Missing RO paths are skipped; workspace +restrict_selffail closed. - #87 Must Fix is actually locked:
test_workspace_only_blocks_glued_shell_expansionscoverscat$IFS/etc/passwd,cat${IFS}/..., ANSI-C, and mid-token$HOME.test_relative_symlink_indirectionplus the Landlockcat 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 > 0placeholder.
🤖 ✨ Landlock is the right bound; fork after NEWPID and stop using 122/123 as isolation sentinels before merge.
Sent by Cursor Automation: Adrianno’s personal code review
| 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); | ||
| } |
There was a problem hiding this comment.
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→Athensh: 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.
| 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; |
There was a problem hiding this comment.
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 123with a valid workspace →sandbox_execreturns -1,sandbox: namespace isolation failed(command ran)exit 122withcfg == 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).
| fprintf(stderr, "test_workspace_landlock_blocks_symlink_escape: mkdtemp failed\n"); | ||
| return 1; | ||
| } | ||
| snprintf(leak_path, sizeof(leak_path), "%s/leak", ws); |
There was a problem hiding this comment.
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.
There was a problem hiding this 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.
Sent by Cursor Approval Agent: Pull Request Router and Approver


Why
workspace_onlyis a string scanner. While it is the primary host-FS gate (sandbox_exechas nopivot_root), Security review will keep finding sibling encodings (\x24, identityPW\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:
workspace_onlyallowlist from #87 (cursor/sandbox-fs-allowlist-d519@3f6528f).Does not mix file/cron/config clusters. Jetson GPU (
/dev/nvhost,/dev/nvgpu,/dev/nvmap) and Argus (/tmp/argus_socket) substring blocklist entries already ondevelopmentare preserved.Kernel bound (
sandbox_exec)workspace_pathis set: RW under the workspace, RO/exec for/bin/usr/lib*plus a small/devand loader set. Missing optional RO paths are skipped; workspace rule +restrict_selffail closed (_exit(122)→sandbox_execreturns-1).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_PRIVSbefore Landlock. cgroups v2 stay best-effort.Scanner (defense-in-depth)
Unchanged intent from #87: quoted/embedded paths,
$HOME/$PWD(including glued$IFSand 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/passwdis denied before exec.Test plan
make test_allowlist CC=gcc— includestest_relative_symlink_indirection(cat leak) plus prior fix(sandbox): harden workspace_only host-FS allowlist #87 casesmake test_sandbox CC=gcc— Landlock symlink +chr(47)+host read blocked; workspace write allowed; host loopback hidden by netnsmake static CC=gccCI=true GATEWAY=1 make test CC=gccResiduals
chr(47)+has no path character in the command text (Landlock is the bound).awk '/foo/',echo HOME=foo, realhttps://host/foo/../bar.pivot_root/ minimal/dev; Landlock does not grant/dev/nv*.Supersedes #87, #74, and #76.