Skip to content

security(sim): sandbox + harden LLM-supplied video output paths (run_policy video=, start_cameras_recording)#930

Merged
cagataycali merged 1 commit into
strands-labs:mainfrom
cagataycali:fix/sim-video-output-path-hardening
Jul 1, 2026
Merged

security(sim): sandbox + harden LLM-supplied video output paths (run_policy video=, start_cameras_recording)#930
cagataycali merged 1 commit into
strands-labs:mainfrom
cagataycali:fix/sim-video-output-path-hardening

Conversation

@cagataycali

Copy link
Copy Markdown
Member

Problem

PR #929 hardened render(output_path=...) against path traversal, symlink, oversize, and partial-write corruption. But render was 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) did os.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=...) and start_cameras_recording_synchronous(...) (rendering.py) did os.path.abspath(output_dir) + os.makedirs(...) with no validation, and interpolated the raw name tag directly into the per-camera filename {tag}__{cam}.mp4 — so name="../../x" escapes output_dir.

Because these paths come from an LLM tool call, a .. traversal, a symlinked target, shell metacharacters, or a name carrying path separators could write outside the intended location.

Change

Centralize the guards in a new module strands_robots/simulation/safe_output.py and share them across all three sinks (single source of truth — render now 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 recording name as a single path component (no separators / traversal / metacharacters).
  • atomic_write_bytes(...) — the atomic temp-file + os.replace writer with 0o600/0o700 perms (moved out of rendering.py).
  • video_sandbox_args() — the video sinks' sandbox policy.

All three sinks now reject an unsafe path with status=error before 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 via STRANDS_ROBOTS_VIDEO_ROOT (STRANDS_ROBOTS_VIDEO_ALLOW_ABS=1 to 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 and STRANDS_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_flag parsing, name-component sanitization, and atomic-write perms + mid-write-failure preservation. The existing render regression test is updated to import the moved atomic_write_bytes.

Green locally: mypy strands_robots tests tests_integ clean (624 files), ruff check/format clean, and the sim recording + policy-runner suites pass (MUJOCO_GL=egl).

Rollout in MuJoCo sim (headless)

Mock-policy rollout on so100, recorded through the hardened run_policy(video=...) path (60 frames, 30 fps, 640x480, 166 KB MP4):

so100 rollout

Guard behavior on the same path (rejected before ffmpeg spawns):

video={"path": "/tmp/artifacts/so100_rollout.mp4"}  -> success | 60 frames, 30fps, 640x480 | 166 KB
video={"path": "../../etc/cron.d/evil.mp4"}         -> error: video recording: unsafe video path: path traversal
video={"path": "/tmp/x;rm -rf ~.mp4"}              -> error: video recording: unsafe video path: shell metacharacters
video={"path": "..\\..\\etc\\passwd.mp4"}           -> error: video recording: unsafe video path: backslash separators not allowed

MP4: https://github.com/cagataycali/robots/releases/download/artifact-sim-video-hardening-1782867886/so100_rollout.mp4

Follow-up to #929.

…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 yinsong1986 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-742 runs before os.makedirs/imageio.get_writer, and rendering.py:1232-1244/1618-1629 run before _os.makedirs and filename interpolation).
  • The name sanitization + output_dir validation combination closes the name="../../x" escape while the per-camera filename joins into the validated out_dir; the cam component 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/vids with run_policy(video={"path": "/etc/x.mp4"}) should error outside the sandbox before ffmpeg spawns.

@cagataycali
cagataycali merged commit b98d4a3 into strands-labs:main Jul 1, 2026
7 checks passed
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.

2 participants