fix(sandbox): harden workspace_only host-FS allowlist - #87
adriannoes wants to merge 26 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>
There was a problem hiding this comment.
Summary
ShellClaw is a C99 edge agent (Jetson / Raspberry Pi). The shell tool’s host-filesystem boundary is this allowlist: sandbox_exec() uses namespaces only and does not pivot_root. This PR hardens workspace_only in src/sandbox/allowlist.c in three real ways: walk the first existing ancestor instead of a lexical prefix when realpath fails, scan quoted/embedded / and ~ on the full command, and expand $HOME/$PWD (fail-closed on other $ forms) on whitespace tokens. strdup OOM is now fail-closed. The ancestor walk and quoted /etc/passwd cases work and are tested. The $ work only applies when a strtok token starts with $, so glued shell expansions still open host files. That is a merge blocker for a change that documents this scanner as the primary host-FS gate.
Must Fix
- Glued
$IFS/${IFS}bypasses the new/scanner.is_fs_absolute_path_start(allowlist.c:184-188) skips/after a path-body letter and after}.cat$IFS/etc/passwdandcat${IFS}/etc/passwdtherefore return allow. Bash word-splits both tocat /etc/passwd./etc/passwdis not in the substring blocklist. - Glued
$HOME/ ANSI-C$...are not fail-closed.has_path_charsonly looks attok[0].cat$'\x20/etc/passwd'expands tocat /etc/passwdand is allowed.python3 -c "open('$HOME/.shellclaw/auth_tokens.json')"andcat"$HOME/.bashrc"are allowed. The tests only cover spacedcat $HOME/...andcat $'\x2f...'.
Verified locally against d9d1a9a (allowlist_check_shell_command rc=0 on those strings; test_allowlist otherwise passed, linked with -lm because this image has no libcurl).
Should Fix
expand_tilde_fragment(allowlist.c:206-208) useshome=""whenHOMEis unset, so~/xbecomes/x. The token expander keeps~/x.docs/SECURITY.mdstill describes the old lexical/token checker. Task 7.1 residual should name glued$/$IFSuntil they are closed.$PWD/../outside.txtintest_workspace_only_blocks_home_env_expansionuses a mkdtemp workspace, not$PWD, so it does not prove..collapse after expansion.- Leftover
(void)workspace_onlyat allowlist.c:343-344. Ifdirnamereturns a pointer intopath_copy,snprintf(path_copy, ..., dir)is overlap UB (same pattern astools/file.c).
Nice to Have
- Conservative false positive:
awk '/foo/' notes.txtis blocked because/foo/looks like a path. Fine if you want that, but operators will hit it. - Python
open(chr(47)+'etc/passwd')has no/in the command string. Residual of not parsing Python; document it, do not turn this module into a language interpreter. - PR title is the branch name. A conventional
fix(sandbox): ...title would match the commits.
Positive Highlights
- Ancestor walk matches
tools/file.cand the newtest_dotdot_escape_nonexistent_destinationactually exercises a missingworkspace/../../tmp/...dest, not only a helper. - Fail-closed
strdupOOM and the quoted/python3 -c open('/etc/passwd')scan close the exact holes called out on earlier PRs. Header comments now state honestly that namespaces are not a chroot.
Sent by Cursor Automation: Adrianno’s personal code review
There was a problem hiding this comment.
Stale comment
Left a non-blocking comment; this automation did not approve. Cursor Bugbot was not present; Cursor Security Agent stayed pending past the 8-minute wait, so human review is required. Requested adriannoes as reviewer per the approval instructions.
Sent by Cursor Approval Agent: Pull Request Router and Approver
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>
There was a problem hiding this comment.
Stale comment
Agentic security review of the sandbox
workspace_onlyallowlist changes. Three additional HIGH containment gaps on the current head (ancestor walk with a missing component before..,file:URL variants, and embedded relative../). Prior review threads on$IFS/}slash skipping and$HOMEexpansion only for whitespace tokens still apply and were not restated.Sent by Cursor Security Agent: Security Reviewer
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>
There was a problem hiding this comment.
Stale comment
Left a non-blocking comment; this automation did not approve. Cursor Bugbot was not present; Cursor Security Agent stayed pending past the 8-minute wait, so human review is required. Requested adriannoes as reviewer per the approval instructions.
Sent by Cursor Approval Agent: Pull Request Router and Approver
There was a problem hiding this comment.
Stale comment
Agentic security review of the
workspace_onlyhost-FS allowlist. Four high-severity containment gaps remain on this head: interpreter slash escapes,$PWD/$HOMEvs in-command assignment, lexical..collapse across symlinks, and undecodedfile:percent-encoding. Prior findings on glued$IFS, mid-token$HOME, missing-dir..walks, and plainfile:URLs look addressed.Sent by Cursor Security Agent: Security Reviewer
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>
There was a problem hiding this comment.
Stale comment
Left a non-blocking comment; this automation did not approve. Cursor Bugbot was not present; Cursor Security Agent stayed pending past the 8-minute wait, so human review is required. Requested adriannoes as reviewer per the approval instructions.
Sent by Cursor Approval Agent: Pull Request Router and Approver
There was a problem hiding this comment.
Stale comment
Agentic security review of the
workspace_onlyhost-FS allowlist. Three residual HIGH bypasses remain in the new scanners: quoted HOME/PWD mutation vs processgetenv, incomplete interpreter-escape reconstruction (encoded.and extra/forms), and quote-splitfile:URLs.Sent by Cursor Security Agent: Security Reviewer
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>
There was a problem hiding this comment.
Stale comment
Left a non-blocking comment; this automation did not approve. Cursor Bugbot was not present; Cursor Security Agent stayed pending past the 8-minute wait, so human review is required. Requested adriannoes as reviewer per the approval instructions.
Sent by Cursor Approval Agent: Pull Request Router and Approver
There was a problem hiding this comment.
Stale comment
Agentic security review of HEAD
925a0dcb6f26ae8eeeebd7e0a82aaddcbc44affb. Prior threads on quotedPWD=/eval, encoded\\x2e/\\u{2f}/\\N{, and quote-splitfile:URLs are addressed. Three remaining HIGH issues:://skipping embedded../, Perl\\x{2f}not reconstructed, and HOME/PWD getenv vsenv/os.environ/comma-nested assignment.Sent by Cursor Security Agent: Security Reviewer
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>
There was a problem hiding this comment.
Stale comment
Left a non-blocking comment; this automation did not approve. Cursor Bugbot was not present; Cursor Security Agent stayed pending past the 8-minute wait, so human review is required. Requested adriannoes as reviewer per the approval instructions.
Sent by Cursor Approval Agent: Pull Request Router and Approver
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>
There was a problem hiding this comment.
Stale comment
Left a non-blocking comment; this automation did not approve. Cursor Bugbot was not present; Cursor Security Agent stayed pending past the 8-minute wait, so human review is required. Requested adriannoes as reviewer per the approval instructions.
Sent by Cursor Approval Agent: Pull Request Router and Approver
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>
There was a problem hiding this comment.
Stale comment
Left a non-blocking comment; this automation did not approve. Cursor Bugbot was not present; Cursor Security Agent stayed pending past the 8-minute wait, so human review is required. Requested adriannoes as reviewer per the approval instructions.
Sent by Cursor Approval Agent: Pull Request Router and Approver
There was a problem hiding this comment.
Stale comment
Agentic security review of the
workspace_onlyhost-FS allowlist. Three HIGH residuals remain in the new scanners: incompletefile:scheme recovery (octal / identity-escape), HOME/PWD mutation forms that still trust processgetenv(), and POSIX backslash-newline continuation that skips the new gates. Prior threads onread/\\o{}/\\x66ile:are addressed on this head.Sent by Cursor Security Agent: Security Reviewer
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>
There was a problem hiding this comment.
Stale comment
Left a non-blocking comment; this automation did not approve. Cursor Bugbot was not present; Cursor Security Agent stayed pending past the 8-minute wait, so human review is required. Requested adriannoes as reviewer per the approval instructions.
Sent by Cursor Approval Agent: Pull Request Router and Approver
| set_reason(reason_buf, reason_cap, "command blocked: out of memory", ""); | ||
| return 1; | ||
| } | ||
| mutated = command_mutates_home_or_pwd(unquoted); |
There was a problem hiding this comment.
🔒 Agentic Security Review
Severity: HIGH
The new HOME/PWD mutation gate still scans a heuristic keyword list on dup_unquoted() (no identity-escape fold) and then expands $PWD/$HOME/~ with this process getenv(). POSIX /bin/sh and bash still change those parameters through forms the scanner misses, so workspace_only allows the command while the executed shell opens a host path outside the workspace.
Impact: With sandbox_exec() chdiring into the workspace and no pivot_root, an auto-executed agent shell command can read or write host files such as /etc/passwd. Live allow (rc=0) cases include export PW\D=; cat $PWD/etc/passwd, \unset HOME; cat $HOME/etc/passwd, \env -i sh -c 'cat $HOME/etc/passwd', bash -c 'declare -n x=PWD; x=; cat $PWD/etc/passwd', and bash -c 'exec -c sh -c "cat $HOME/etc/passwd"'.
Reviewed by Cursor Security Reviewer for commit 2d0fc3e. Configure here.
| if (file_blocked) | ||
| return 1; | ||
| } | ||
| if (block_if_dollar_expansions_escape(cmd, workspace_root, reason_buf, reason_cap)) |
There was a problem hiding this comment.
🔒 Agentic Security Review
Severity: HIGH
block_if_dollar_expansions_escape() only looks for a literal $ on the raw command. Hex/octal/unicode decoding is applied to file: recovery, not to $ (\x24 / \044 / \u0024). A Python/Perl string can therefore hide $HOME so the new expansion gate never runs, and is_fs_absolute_path_start() also skips the following / because it sits after an identifier (E/D).
Impact: Direct cat $HOME/.bashrc stays blocked, but python3 -c "import os; os.system('cat \\x24HOME/.bashrc')" is allowed (rc=0) and the interpreter expands the real home, which is typically outside ~/.shellclaw. That can expose host files such as ~/.bashrc, ~/.ssh/config, or ~/.git-credentials.
Reviewed by Cursor Security Reviewer for commit 2d0fc3e. Configure here.
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>
There was a problem hiding this comment.
Left a non-blocking comment; this automation did not approve. Cursor Bugbot was not present; Cursor Security Agent stayed pending past the 8-minute wait, so human review is required. Requested adriannoes as reviewer per the approval instructions.
Sent by Cursor Approval Agent: Pull Request Router and Approver
|
Superseded by #88 ( The allowlist commits from this PR were replayed onto |




Rewrite of Bugbot drafts #66 + 68a + #82 onto
development.sandbox_exec()does notpivot_root.workspace_onlyis the host-FS gate:realpathfails (workspace/../../tmp/stolen)../..lexically first so a missing directory before..(/ws/nope/../../../tmp/stolen) cannot pin the walk at the workspace./and~on the full command (cat '/etc/passwd',python3 -c "open('/etc/passwd')").../to the workspace (python3 -c "open('../secret')").file:URL paths (file:/etc/passwd,file://localhost/etc/passwd,file://etc/passwd).$HOME/${HOME}/$PWD/${PWD}on the full command, including glued forms (cat$IFS/etc/passwd,cat"$HOME/.bashrc"). Other$forms fail closed.\x2f,\57,\u002f) as/plus the following path body (python3 -c "open('\x2fetc/passwd')").HOME=/PWD=assignment,export, andunsetinstead of trusting process getenv (PWD=; cat $PWD/etc/passwd)...across a symlink (workspace/out/../etc/passwdwithout→/).file:URLs (%2e%2e,%2f) before the workspace check.https://stays allowed.eval/sh -cHOME=/PWD=mutation (eval 'PWD=; cat $PWD/etc/passwd').file:schemes (f'ile://localhost/etc/passwd',f"ile:/etc/passwd",'f'+'ile://...') before the URL check.https://stays allowed..(\x2e,\56,\u002e) forming../, and extra slash encodings (\u{2f}); fail-closed on\N{without parsing Unicode names.../after://(https://example.com/../../../../etc/passwd); realhttps://without a..walk stays allowed.\x{2f}/\x{2e}like\u{2f}.env -i,env -u HOME|PWD,os.environ.pop/delof HOME/PWD, andHOME=/PWD=after a comma.read HOME|PWD, GNUenv --unset/--unset=, clusteredenv -iu, and Pythonos.unsetenv/os.putenv/os.environ.clear.\o{57}/\o{057}/\o{056}like\x{2f}.\x66ile:,\u0066ile:) before thefile:check.https://stays allowed.\146ile:,\072) and fold shell identity escapes (f\ile:) before thefile:check.https://stays allowed.\+ newline (optional CR) before HOME/PWD andfile:scans (f\<newline>ile:,PW\<newline>D=).printf -v HOME|PWD,os.environ["HOME"]=, andos.environ.update({"HOME":...}).Residuals (not this PR):
cdwith no./~$(Landlock cluster fix(sandbox): Landlock + relative-token block for workspace FS escapes #74+fix(sandbox): fail closed when namespace isolation cannot be applied #76).open(chr(47)+'etc/passwd')(no path character or slash encoding in the command string).awk '/foo/',echo HOME=foo.Supersedes #66 and #82.
Does not start Landlock (#74 + #76). Operator merges.