security(sim): sandbox + harden render(output_path=...) against traversal, symlink, oversize, partial-write#929
Conversation
…rsal, symlink, oversize, partial-write render(output_path=...) is an LLM-callable tool, so its destination is attacker-influenced. The prior guard (metachar blacklist + a /-only ".." check) still accepted absolute paths (/etc/cron.d/x), backslash traversal (..\..\etc\passwd), and symlinked targets, and wrote non-atomically with no size cap. - Confine writes to a sandbox root (STRANDS_ROBOTS_RENDER_ROOT, default ~/.strands_robots/renders); opt out with STRANDS_ROBOTS_RENDER_ALLOW_ABS=1. - Reject backslash separators and symlinked targets; resolve() normalizes .. and follows the chain so the sandbox check sees the true destination. - Cap payload at STRANDS_ROBOTS_RENDER_MAX_BYTES (default 50 MB). - Atomic write via tempfile.mkstemp + os.replace; created files 0o644, freshly created dirs 0o755. Adds tests/simulation/mujoco/test_render_output_path.py (GL-free, cross-platform) covering each vector; documents the env vars in README and CHANGELOG.
yinsong1986
left a comment
There was a problem hiding this comment.
Summary
Hardens Simulation.render(output_path=...) (an LLM-callable, attacker-influenced sink) against the arbitrary-write vectors that the #915 metachar-blacklist guard let through. The inline guard is replaced by three GL-free helpers in rendering.py: _validate_render_output_path (rejects shell metacharacters, backslash separators, symlinked targets, and confines the Path.resolve()-normalized destination to a sandbox root), a size cap via _max_render_bytes, and _atomic_write_png (tempfile.mkstemp + os.replace with temp cleanup on failure). Three new documented env vars (STRANDS_ROBOTS_RENDER_ROOT, STRANDS_ROBOTS_RENDER_ALLOW_ABS, STRANDS_ROBOTS_RENDER_MAX_BYTES) control sandbox root, absolute opt-out, and the cap.
The diff matches its description and is a net security improvement. The validation logic is sound: .. traversal and absolute escapes are caught by resolve() + relative_to(root); intermediate symlinks that escape the sandbox are also caught by the same containment check; oversize payloads are rejected before any write. The error contract is intact — _save_render_png raises ValueError mapped to a tool error dict, and an OSError from _atomic_write_png (e.g. EACCES creating the parent dir) is caught by render()'s outer except Exception and returned as a structured status=error rather than crashing the hot path. New env vars are documented in README per the PR #86 "document every env var" learning, and the validate-before-sink pattern follows the PR #92 LLM-input-safety convention. 22 GL-free regression tests pin each vector. No blocking concerns found.
CodeQL flagged the explicit 0o644 file / 0o755 dir chmod in the render output sandbox as overly permissive (world-readable). The sandbox is private to the running user, so restrict created files to 0o600 and freshly created dirs to 0o700. Update perms assertions accordingly.
|
Addressed the two CodeQL "overly permissive file permissions" alerts (593/594): the render sandbox is private to the running user, so created files are now |
yinsong1986
left a comment
There was a problem hiding this comment.
Summary
Hardens the LLM-callable Simulation.render(output_path=...) sink (added in #915) against the arbitrary-write vectors the old metachar-blacklist + /-only .. guard let through. The inline block is replaced by three GL-free helpers in rendering.py: _validate_render_output_path (rejects shell metacharacters, backslash separators, and symlinked final targets, then Path.resolve()-normalizes ../symlink chains and confines the destination under a sandbox root), a size cap via _max_render_bytes, and _atomic_write_png (tempfile.mkstemp + os.replace with owner-only 0o600/0o700 perms). The guard is fail-closed: absolute paths, sibling-of-sandbox prefixes, and CWD-relative paths that resolve outside the root are all rejected unless the operator opts in with STRANDS_ROBOTS_RENDER_ALLOW_ABS=1. I traced each advertised vector and confirmed the resolve()+relative_to() confinement is path-component aware (not a string-prefix compare) and that intermediate symlinks are resolved before the sandbox check.
What's good
- New env vars (
STRANDS_ROBOTS_RENDER_ROOT/_ALLOW_ABS/_MAX_BYTES) are documented in README's Configuration table and CHANGELOG, matching the AGENTS.md rule that everySTRANDS_*var is documented in the same PR. - Error paths return structured
{"status": "error", ...}dicts (ValueErrorcaught inrender()); no uncaught exceptions on the hot path. - Malformed
STRANDS_ROBOTS_RENDER_MAX_BYTESraises rather than silently defaulting - matches AGENTS.md "warn/raise on unrecognized env values". - The CodeQL world-readable finding on the prior commit (
0o644/0o755) is already addressed in the head commit (0o600/0o700), with matching perms assertions in the tests. - Comprehensive GL-free regression tests cover traversal, backslash, symlink, absolute opt-in, oversize, malformed-cap, perms, and atomic-write-failure-preserves-existing.
Verification suggestions
Standard CI (hatch run test, ruff, mypy) is sufficient. To spot-check confinement interactively: STRANDS_ROBOTS_RENDER_ROOT=/tmp/sbx python -c "from strands_robots.simulation.mujoco.rendering import _validate_render_output_path as v; v('/etc/cron.d/x')" should raise ValueError (outside sandbox).
|
Closing as already-merged. The full substance of this PR is now present on |
Summary
Simulation.render(output_path=...)(added in #915) is an LLM-callable tool, so its destination path is attacker-influenced. The guard shipped in #915 was a shell-metacharacter blacklist plus a/-only..check, which leaves several arbitrary-write vectors open. This hardens it.Vectors the current guard accepts (pre-fix)
There is also no payload-size cap and the write is non-atomic (
open(path,'wb')truncates the target before the write completes, so a crash mid-write corrupts an existing file in place).Change
Replaces the inline block in
render()with three small, GL-free helpers instrands_robots/simulation/mujoco/rendering.py:_validate_render_output_path- rejects shell metacharacters, backslash separators, and symlinked targets;Path.resolve()normalizes..and follows the chain, then the destination is confined to a sandbox root._max_render_bytes/ size cap - PNGs larger than the limit are refused without writing._atomic_write_png-tempfile.mkstemp+os.replace, so a crash mid-write cannot corrupt an existing file. The render sandbox is private to the running user, so created files are0o600and freshly created dirs0o700(owner-only, no world/group access).New configuration (documented in README)
STRANDS_ROBOTS_RENDER_ROOTrender(output_path=...)may write into~/.strands_robots/renders/STRANDS_ROBOTS_RENDER_ALLOW_ABS1to allow absolute paths outside the sandboxSTRANDS_ROBOTS_RENDER_MAX_BYTES52428800(50 MB)Tests
tests/simulation/mujoco/test_render_output_path.py(22 cases, GL-free, Linux+macOS). Each vector above is asserted rejected, plus: valid in-sandbox write, absolute opt-in, oversize rejection, malformed size-cap env, owner-only perms (0o600/0o700), and an atomic-write test that monkeypatchesos.replaceto fail and asserts the existing file content is preserved with no temp-file leak. The traversal/symlink/absolute cases fail against the pre-fix code and pass after.Lint clean:
ruff check,ruff format --check,mypy strands_robots tests tests_integ(Success, 0 issues).Visual proof (headless MuJoCo, MUJOCO_GL=egl)
SO-101 rendered via the hardened path: a valid in-sandbox path saves (
0o600), while an absolute escape and backslash traversal are rejected withstatus=error.Follow-up (out of scope here):
start_cameras_recording/export_xml(output_path=...)take LLM-supplied paths with the same metachar-only pattern and should get the same treatment.Refs #915.