Skip to content

fix(sandbox,shell): hide host secrets and enforce the command timeout - #125

Merged
adriannoes merged 11 commits into
developmentfrom
fix/sandbox-shell-hardening
Oct 17, 2026
Merged

adriannoes merged 11 commits into
developmentfrom
fix/sandbox-shell-hardening

Conversation

@adriannoes

Copy link
Copy Markdown
Collaborator

Summary

Consolidates two cursor high-severity sandbox/shell fixes as operator-authored commits:

  • fix(sandbox): hide host secrets from sandboxed shell commands (supersedes fix(sandbox): hide host secrets from sandboxed shell commands #109) — sandboxed commands previously inherited host secrets from the environment; the sandbox now builds a filtered environment (new src/sandbox/sandbox_env.c/.h) so credentials do not leak into sandboxed processes.
  • fix(shell): stop trickle output from bypassing the command timeout (supersedes fix(shell): stop trickle output from bypassing the command timeout #116) — a command slowly trickling output kept resetting the timeout, so the agent lock stayed held indefinitely; the deadline is now a true wall clock bound in both the output cap and the sandbox pipe loop.

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 failed
  • CI=true make test — passed
  • make static — passed

Supersedes #109 and #116.

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.

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

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

  1. CHANGELOG.md has no [Unreleased] Fixed entry for either change. The env scrub is user-visible (LD_*, proxy and CUDA_* variables no longer reach sh -c), so it needs one.
  2. The unsandboxed path still leaks every secret. run_unsandboxed in src/tools/shell.c still forks with the full agent environment. sandbox.enabled defaults to off when the TOML key is omitted, and docs/SECURITY.md line 43 only says "not a security boundary". Please state explicitly that printenv works there, or scrub there too (reusing sandbox_scrub_command_environment is cheap).
  3. The scrubbed environment is a behavior change and is undocumented. sandbox_env.c keeps only PATH, LANG, LC_ALL, LC_CTYPE, TERM, TZ and HOME. On Jetson, anything that relies on LD_LIBRARY_PATH, CUDA_HOME, TMPDIR, SSL_CERT_FILE or proxy variables will break inside the sandbox. If that is intended, list it in docs/SECURITY.md and the CHANGELOG. An optional passthrough list in [sandbox] would help later.
  4. The timeout logic is duplicated. monotonic_elapsed_ms is copied verbatim into sandbox.c:114 and shell.c:70. The have_clock fallback and the remain < 1 branch are also duplicated. The remain < 1 branch is unreachable because the loop already breaks at elapsed >= timeout. CLOCK_MONOTONIC failing is not a realistic case, so I would drop have_clock. One small shared deadline helper would cut this, and would bring drain_pipe (about 60 lines) and run_unsandboxed (about 85 lines) closer to the 40-line guideline in AGENTS.md.
  5. The env test is weaker than it looks (tests/test_sandbox.c, test_command_does_not_inherit_host_secrets):
    • /proc/1/environ inside the new PID namespace is the command itself after clearenv, so that line checks the same thing as printenv.
    • The test passes NULL as the workspace, so the HOME=workspace branch is never exercised.
    • It does not check that LD_PRELOAD is dropped or that PATH is kept. The PATH_OK marker only proves the shell ran.
    • Add a case with a workspace that asserts HOME, and one that asserts LD_PRELOAD is absent.
  6. The trickle tests only assert the elapsed time and the "timed out" text. Neither checks that the command was actually killed. Add a /proc scan for the leftover sleep, as test_shell_caps_output_kills_background_sleep does. That is the property the group kill and PDEATHSIG exist to provide.

Nice to Have

  • sandbox_scrub_command_environment runs strdup, clearenv and setenv in 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 inside setenv/putenv at fork time. A safer shape is to build an envp[] in the parent before fork() and call execle. That would also remove the #if !defined(__linux__) environ branch and the strdup/free helper.
  • drain_pipe was reformatted in places unrelated to the fix (for example if (errno == EINTR) continue; split across lines). It makes the diff noisier than it needs to be.
  • Pre-existing, but now more visible: shell.c:265 computes timeout_sec * 1000 as an int, and the new (long)timeout_sec * 1000L overflows on 32-bit long for large configured values. Clamp shell_timeout_sec at 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_pipe explains why.
  • The scrub runs in the command process after cgroup join, namespaces, Landlock and close_inherited_fds, and just before execl, 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 SIGALRM watchdog, 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.
Open in Web View Automation 

Sent by Cursor Automation: Code review

@adriannoes adriannoes changed the title fix(sandbox,shell): hide host secrets and enforce the command timeout (supersedes #109, #116) fix(sandbox,shell): hide host secrets and enforce the command timeout Oct 16, 2026
…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.
@adriannoes adriannoes self-assigned this Oct 16, 2026
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
@adriannoes

adriannoes commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator Author

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
@adriannoes
adriannoes merged commit 026e2b9 into development Oct 17, 2026
1 check passed
@adriannoes
adriannoes deleted the fix/sandbox-shell-hardening branch October 17, 2026 15:21
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