security(sim): sandbox + harden LLM-supplied video output paths (run_policy video=, start_cameras_recording)#930
Conversation
…policy video=, start_cameras_recording)
render(output_path=...) was hardened in isolation, but the sibling video sinks
that also persist an LLM-supplied filesystem path were still unguarded:
run_policy(video={"path": ...}) and start_cameras_recording(output_dir=...,
name=...) did os.path.abspath + os.makedirs on the raw path and interpolated the
raw name tag into the per-camera filename, so a .. traversal, a symlinked
target, shell metacharacters, or a name carrying path separators could escape
the intended location.
Centralize the guards in strands_robots.simulation.safe_output and share them
across all three sinks: reject .. traversal, backslash separators, shell
metacharacters, and symlinked targets with status=error before any file is
opened; validate the recording name as a single path component. Unlike render
(sandbox on by default), the video sinks preserve their historic absolute-path
contract - confinement is opt-in via STRANDS_ROBOTS_VIDEO_ROOT (with
STRANDS_ROBOTS_VIDEO_ALLOW_ABS=1 to re-permit absolute paths). render now
delegates to the shared helpers; its behavior and STRANDS_ROBOTS_RENDER_* env
vars are unchanged.
Adds tests/simulation/test_output_path_sandbox.py (guards, opt-in sandbox,
name sanitization, atomic write) and updates the render regression test to
import the moved atomic_write_bytes. CHANGELOG + README + sink docstrings
updated.
yinsong1986
left a comment
There was a problem hiding this comment.
Summary
This PR extends the output-path hardening from #929 (render(output_path=...)) to the two sibling video sinks that also persist an LLM-supplied filesystem path: run_policy(video={"path": ...}) and start_cameras_recording(output_dir=..., name=...) (plus its synchronous variant). The guards are centralized in a new strands_robots/simulation/safe_output.py module and shared by all three sinks: validate_output_path rejects .. traversal, backslash separators, shell metacharacters, empty paths, and symlinked targets (with optional sandbox confinement); sanitize_name_component validates the recording name as a single path component before it is interpolated into {tag}__{cam}.mp4; atomic_write_bytes is the moved atomic writer; render now delegates to the shared helpers with its behavior and STRANDS_ROBOTS_RENDER_* env vars unchanged. Validation runs before any makedirs/writer open/ffmpeg spawn, and both sinks return status=error on rejection rather than raising.
What's good
- Both consumer sites validate before the first side effect (verified
policy_runner.py:736-742runs beforeos.makedirs/imageio.get_writer, andrendering.py:1232-1244/1618-1629run before_os.makedirsand filename interpolation). - The
namesanitization +output_dirvalidation combination closes thename="../../x"escape while the per-camera filename joins into the validatedout_dir; thecamcomponent is model-derived (_active_camera_list->mj_id2name), not raw LLM input, so it can't inject separators. - Backward-compatible: historic absolute-path contract preserved, sandbox is opt-in via documented env vars, and both new
STRANDS_ROBOTS_VIDEO_*vars are added to the README Configuration table per the AGENTS.md convention. - Tool boundaries return structured error dicts (not raises), no emojis in user-facing strings, no host paths in tests.
Verification suggestions
python -m pytest tests/simulation/test_output_path_sandbox.py tests/simulation/mujoco/test_render_output_path.py --no-cov(63 pass locally).- Spot-check the sandbox opt-in end to end:
STRANDS_ROBOTS_VIDEO_ROOT=/tmp/vidswithrun_policy(video={"path": "/etc/x.mp4"})should erroroutside the sandboxbefore ffmpeg spawns.
Problem
PR #929 hardened
render(output_path=...)against path traversal, symlink, oversize, and partial-write corruption. Butrenderwas only one of several simulation entry points that persist an LLM-supplied filesystem path to disk. Two sibling video sinks were left with the old, weaker pattern:run_policy(video={"path": ...})(policy_runner.py) didos.makedirs(os.path.dirname(os.path.abspath(video_path)))and opened an imageio writer on the raw path with no validation at all.start_cameras_recording(output_dir=..., name=...)andstart_cameras_recording_synchronous(...)(rendering.py) didos.path.abspath(output_dir)+os.makedirs(...)with no validation, and interpolated the rawnametag directly into the per-camera filename{tag}__{cam}.mp4— soname="../../x"escapesoutput_dir.Because these paths come from an LLM tool call, a
..traversal, a symlinked target, shell metacharacters, or anamecarrying path separators could write outside the intended location.Change
Centralize the guards in a new module
strands_robots/simulation/safe_output.pyand share them across all three sinks (single source of truth —rendernow delegates to it instead of keeping its own copy):validate_output_path(...)— rejects..traversal segments, backslash separators, shell metacharacters (;|$`><NUL, CR/LF), empty paths, and a symlinked target; normalizes and (optionally) confines to a sandbox root.sanitize_name_component(...)— validates the recordingnameas a single path component (no separators / traversal / metacharacters).atomic_write_bytes(...)— the atomic temp-file +os.replacewriter with0o600/0o700perms (moved out ofrendering.py).video_sandbox_args()— the video sinks' sandbox policy.All three sinks now reject an unsafe path with
status=errorbefore any file is opened or ffmpeg process spawns.Backward compatibility
Unlike
render(sandbox on by default), the video/recording sinks have historically accepted arbitrary absolute paths (/tmp/foo.mp4,str(tmp_path)), and that contract is preserved: absolute paths without..are still allowed. Sandbox confinement is opt-in viaSTRANDS_ROBOTS_VIDEO_ROOT(STRANDS_ROBOTS_VIDEO_ALLOW_ABS=1to re-permit absolute paths inside that mode). The always-on guards (traversal, backslash, metacharacters, symlink, name-injection) close the sharpest edges without breaking existing callers.render's behavior andSTRANDS_ROBOTS_RENDER_*env vars are unchanged.Tests
tests/simulation/test_output_path_sandbox.py(38 cases): metacharacter / backslash / empty / symlink / traversal rejection, opt-in sandbox confinement + allow-abs override,env_flagparsing,name-component sanitization, and atomic-write perms + mid-write-failure preservation. The existing render regression test is updated to import the movedatomic_write_bytes.Green locally:
mypy strands_robots tests tests_integclean (624 files),ruff check/formatclean, and the sim recording + policy-runner suites pass (MUJOCO_GL=egl).Rollout in MuJoCo sim (headless)
Mock-policy rollout on
so100, recorded through the hardenedrun_policy(video=...)path (60 frames, 30 fps, 640x480, 166 KB MP4):Guard behavior on the same path (rejected before ffmpeg spawns):
MP4: https://github.com/cagataycali/robots/releases/download/artifact-sim-video-hardening-1782867886/so100_rollout.mp4
Follow-up to #929.