Repository navigation
fix(sandbox,shell): hide host secrets and enforce the command timeout - #125
Conversation
The command child inherited the agent environment, so printenv inside sh -c returned provider API keys and bot tokens on the tool stdout channel. Replace that environment with PATH, locale, and workspace HOME before execl. Supersedes #109.
The shell and sandbox wait loops only advanced elapsed time when poll returned idle. A command that wrote faster than the poll slice never hit shell_timeout_sec or sandbox_exec's timeout, so the agent lock stayed held until the output buffer filled. Count monotonic wall time in both loops and still report the deadline when the child exits because the pipe was closed. Supersedes #116.
There was a problem hiding this comment.
Context. ShellClaw is a C99 edge agent (Jetson / Raspberry Pi) built with Make, one test binary per module. The shell tool runs commands either through sandbox_exec (namespaces, Landlock, cgroups) or a plain fork fallback. This PR is in src/sandbox and src/tools/shell.c.
Summary. The PR does two things. The sandboxed command no longer inherits the agent's environment, so a prompt-injected printenv cannot read API keys or bot tokens. Both shell paths (sandboxed and unsandboxed) now enforce the timeout against a monotonic wall clock instead of an idle-poll counter. Before, a command that kept writing never advanced the counter, so the agent lock stayed held until the output buffer filled. I built and ran test_sandbox and test_shell here and both pass. The two new regression tests ran and were not skipped. I found no blockers.
Must Fix
- None.
Should Fix
CHANGELOG.mdhas no[Unreleased]Fixed entry for either change. The env scrub is user-visible (LD_*, proxy andCUDA_*variables no longer reachsh -c), so it needs one.- The unsandboxed path still leaks every secret.
run_unsandboxedinsrc/tools/shell.cstill forks with the full agent environment.sandbox.enableddefaults to off when the TOML key is omitted, anddocs/SECURITY.mdline 43 only says "not a security boundary". Please state explicitly thatprintenvworks there, or scrub there too (reusingsandbox_scrub_command_environmentis cheap). - The scrubbed environment is a behavior change and is undocumented.
sandbox_env.ckeeps onlyPATH,LANG,LC_ALL,LC_CTYPE,TERM,TZandHOME. On Jetson, anything that relies onLD_LIBRARY_PATH,CUDA_HOME,TMPDIR,SSL_CERT_FILEor proxy variables will break inside the sandbox. If that is intended, list it indocs/SECURITY.mdand the CHANGELOG. An optional passthrough list in[sandbox]would help later. - The timeout logic is duplicated.
monotonic_elapsed_msis copied verbatim intosandbox.c:114andshell.c:70. Thehave_clockfallback and theremain < 1branch are also duplicated. Theremain < 1branch is unreachable because the loop already breaks atelapsed >= timeout.CLOCK_MONOTONICfailing is not a realistic case, so I would drophave_clock. One small shared deadline helper would cut this, and would bringdrain_pipe(about 60 lines) andrun_unsandboxed(about 85 lines) closer to the 40-line guideline inAGENTS.md. - The env test is weaker than it looks (
tests/test_sandbox.c,test_command_does_not_inherit_host_secrets):/proc/1/environinside the new PID namespace is the command itself afterclearenv, so that line checks the same thing asprintenv.- The test passes
NULLas the workspace, so theHOME=workspacebranch is never exercised. - It does not check that
LD_PRELOADis dropped or thatPATHis kept. ThePATH_OKmarker only proves the shell ran. - Add a case with a workspace that asserts
HOME, and one that assertsLD_PRELOADis absent.
- The trickle tests only assert the elapsed time and the "timed out" text. Neither checks that the command was actually killed. Add a
/procscan for the leftoversleep, astest_shell_caps_output_kills_background_sleepdoes. That is the property the group kill andPDEATHSIGexist to provide.
Nice to Have
sandbox_scrub_command_environmentrunsstrdup,clearenvandsetenvin a forked child of a multithreaded process (the gateway has an lws thread). glibc's env lock is not reset in the child, so this could deadlock if another thread is insidesetenv/putenvat fork time. A safer shape is to build anenvp[]in the parent beforefork()and callexecle. That would also remove the#if !defined(__linux__)environbranch and the strdup/free helper.drain_pipewas reformatted in places unrelated to the fix (for exampleif (errno == EINTR) continue;split across lines). It makes the diff noisier than it needs to be.- Pre-existing, but now more visible:
shell.c:265computestimeout_sec * 1000as anint, and the new(long)timeout_sec * 1000Loverflows on 32-bitlongfor large configured values. Clampshell_timeout_secat config load. - The sandbox timeout clock starts only after the isolation handshake, so a slow setup is not counted. That is fine, but worth one sentence in the header comment.
Positive Highlights
- The root cause is fixed at the right place. Switching from "sum of idle poll slices" to a monotonic deadline covers both the sandbox and unsandboxed loops, and the new comment in
drain_pipeexplains why. - The scrub runs in the command process after cgroup join, namespaces, Landlock and
close_inherited_fds, and just beforeexecl, so setup code still has what it needs and nothing leaks into the command.LD_*is dropped as a side benefit. - The two regression tests use a
SIGALRMwatchdog, so a regression fails fast instead of hanging CI. Each test cleans up before asserting. The Makefile wiring for the new object (SANDBOX_O,clean) is complete.
Sent by Cursor Automation: Code review
…mespaces GitHub-hosted runners reject unshare(CLONE_NEWUSER), so the sandbox_exec success-path tests skip there (namespace isolation failed) and the wall-clock rewrite of drain_pipe plus the secret-free child environment stayed uncovered on CI, dropping the agent-core coverage gate to 79.8%. Expose the pipe helpers under SHELLCLAW_SANDBOX_TEST (CR-21 pattern) and link them from tests/test_sandbox.c, which now covers poll_remain_ms bounds, the monotonic clamp, drain-until-EOF and the wall deadline with a trickle writer, plus the scrubbed child environment via a forked probe.
…ge capture scripts/coverage.sh captures --directory src only; a unit-test object in build/ produced a .gcda the capture never saw, so drain_pipe stayed uncovered and the agent-core gate fell to 79.1%. The ws_test.o pattern already keeps its test object under src/ for the same reason.
The unit-test step builds src/sandbox/sandbox_test.o without --coverage in the same runner workdir; make clean did not delete it, so the coverage build reused the uninstrumented object and the capture lost the drain_pipe traces again. Precedent: WS_TEST_O is pruned the same way.
monotonic_elapsed_ms was duplicated verbatim between sandbox.c and shell.c (PR #125 review, Should Fix 4). Export it and poll_remain_ms through sandbox.h so both timeout loops run one implementation, and drop the two pieces of dead weight the review called out: - the have_clock fallback: CLOCK_MONOTONIC failure is not a realistic case (vDSO), and if it ever failed the deadline simply never fires while poll slices stay bounded; - the remain < 1 clamp in poll_remain_ms: unreachable, both loops break at elapsed >= timeout before calling the helper. The sandbox_exec doc now states that the timeout clock starts only after the isolation handshake (review Nice item 4). test_sandbox.c takes the two helper prototypes from the header (the drain_pipe prototype stays: it only becomes a global under SHELLCLAW_SANDBOX_TEST) and drops the assertion that covered the removed clamp branch. No behavior change: poll bounds, monotonic clamp, drain EOF, wall deadline and trickle timeout tests stay green. Refs: #125
Per the PR #125 review (Should Fix 5) the regressions were weaker than they looked: the scrub-child unit never checked that LD_PRELOAD is dropped, and the sandbox_exec secret test passed a NULL workspace, so the HOME=workspace branch of the scrub never ran. test_scrubbed_env_child_loses_secrets now sets LD_PRELOAD before the scrub and asserts it absent after; the child never execs, so injecting the variable is safe on every runner. test_command_does_not_inherit_ host_secrets now runs against a real mkdtemp workspace and asserts the workspace path is visible as HOME in the command output. Refs: #125
Per the PR #125 review (Should Fix 6) the trickle output timeout tests only asserted the elapsed time and the 'timed out' text; neither proved the command tree was actually killed, which is the whole point of the group kill and PR_SET_PDEATHSIG on the deadline path. Both trickle tests now leave a marker sleep 9999 in the command's process group. test_shell reuses the count_sleep_argv / sleep_argv_did_not_grow pattern from the output-cap tests to prove no marker sleeps survive; test_sandbox scans /proc with the same retry loop test_timeout_kills_process already uses (Linux-only, matching that precedent: the non-Linux fallback has no process-group kill). Tests only; src/ is untouched. Refs: #125
Per the PR #125 review (Should Fix 1-3) both behavior changes were user-visible but undocumented: - CHANGELOG [Unreleased]/Fixed now carries the env scrub (sandboxed commands keep only the whitelist, so LD_*, CUDA_* and proxy variables no longer reach sh -c) and the wall-clock timeout enforcement on both shell paths. - docs/SECURITY.md gains an explicit command environment model: the scrubbed whitelist for the sandboxed path, the Jetson-relevant variables that do not pass (LD_LIBRARY_PATH, CUDA_*, TMPDIR, SSL_CERT_FILE, proxies, no passthrough list in v1.0), and the intentional full-host-environment behavior of the sandbox.enabled=false fallback, where printenv works because it is reduced isolation, not a security boundary. Refs: #125
Fallout from sharing the wall-clock helpers: without the have_clock fallback both deadline loops recompute elapsed_ms at the top of every iteration, so the function-level '= 0' initializer became dead code and cppcheck now reports unreadVariable/variableScope in both files. Declare the counter inside the loops next to the other iteration locals instead. No behavior change; test_sandbox and test_shell stay green. Refs: #125
|
Review response — done in 026eca8 (deadline helpers now live once in sandbox.h and shell.c drops its copy; have_clock fallback and the unreachable remain<1 clamp removed), 4d4d356 (env test asserts LD_PRELOAD is dropped and PATH kept; the Linux test now passes a workspace so HOME is covered), 9fb01d0 (trickle timeout tests assert the sleep is actually killed), c37b031 (SECURITY.md env model and changelog), d708a8c (loop-scoping fix cppcheck required after the have_clock drop). Deferred: building envp[] before fork with execle (real but rare fork hazard — architectural follow-up) and clamping shell_timeout_sec at config load. |
# Conflicts: # CHANGELOG.md # Makefile


Summary
Consolidates two cursor high-severity sandbox/shell fixes as operator-authored commits:
Consolidation note: two cppcheck variableScope findings introduced by #116 were resolved in-place (loop-local wall timers moved into their use site).
Testing
make test_sandbox— passed (namespace tests skipped on macOS, as designed)make test_shell— 30 tests, 0 failedCI=true make test— passedmake static— passedSupersedes #109 and #116.