Skip to content

Commit b98d4a3

Browse files
authored
security(sim): sandbox + harden LLM-supplied video output paths (run_policy video=, start_cameras_recording) (#930)
1 parent a07e64d commit b98d4a3

7 files changed

Lines changed: 480 additions & 82 deletions

File tree

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,26 @@ so a crash mid-write cannot corrupt an existing file; created files are `0o644`
2222
and freshly created directories `0o755`.
2323

2424

25+
### Security: Extended output-path hardening to `run_policy(video=...)` and `start_cameras_recording`
26+
27+
`render(output_path=...)` was hardened in isolation, but the sibling video sinks
28+
that also persist an LLM-supplied filesystem path were still unguarded:
29+
`run_policy(video={"path": ...})` and `start_cameras_recording(output_dir=...,
30+
name=...)` did `os.path.abspath` + `os.makedirs` on the raw path (and
31+
interpolated the raw `name` tag into the per-camera filename), so a `..`
32+
traversal, a symlinked target, shell metacharacters, or a `name` carrying path
33+
separators could escape the intended location. The guards are now centralized in
34+
`strands_robots.simulation.safe_output` and shared by all three sinks: `..`
35+
traversal segments, backslash separators, shell metacharacters, and symlinked
36+
targets are rejected with `status=error` before any file is opened, and the
37+
recording `name` is validated as a single path component. Unlike `render`, whose
38+
sandbox is on by default, the video sinks preserve their historic
39+
absolute-path contract: confinement is opt-in via `STRANDS_ROBOTS_VIDEO_ROOT`
40+
(with `STRANDS_ROBOTS_VIDEO_ALLOW_ABS=1` to re-permit absolute paths inside that
41+
mode). The `render` implementation now delegates to the shared helpers; its
42+
behavior and env vars (`STRANDS_ROBOTS_RENDER_*`) are unchanged.
43+
44+
2545
### Added: `BaseRLAlgo.evaluate()` - deterministic eval peer of `train()`
2646

2747
The from-scratch RL trainers (`PpoTrainer`, `FastSacTrainer`) could `train()` a

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -986,6 +986,8 @@ touches ROS 2.
986986
| `STRANDS_ROBOTS_RENDER_ROOT` | Sandbox directory that `Simulation.render(output_path=...)` may write into | `~/.strands_robots/renders/` |
987987
| `STRANDS_ROBOTS_RENDER_ALLOW_ABS` | Set `1` to allow `render(output_path=...)` to write absolute paths outside the render sandbox | unset |
988988
| `STRANDS_ROBOTS_RENDER_MAX_BYTES` | Max PNG size `render(output_path=...)` will persist | `52428800` (50 MB) |
989+
| `STRANDS_ROBOTS_VIDEO_ROOT` | Opt-in sandbox for video/recording output paths (`run_policy(video=...)`, `start_cameras_recording`). Unset = absolute paths allowed (historic contract); set to confine writes | unset |
990+
| `STRANDS_ROBOTS_VIDEO_ALLOW_ABS` | Set `1` to re-permit absolute paths when `STRANDS_ROBOTS_VIDEO_ROOT` is set | unset |
989991
| `STRANDS_TRUST_REMOTE_CODE` | Set `1` to allow HF `trust_remote_code` for `lerobot_local` | unset |
990992
| `STRANDS_ROBOTS_NO_DYLD_SHIM` | Set `1` to disable the macOS auto-fix that puts Homebrew ffmpeg on the dyld path for torchcodec video streaming (see [Recording & streaming datasets](#recording--streaming-datasets)) | unset |
991993
| `MUJOCO_GL` | MuJoCo GL backend (`egl`, `osmesa`, `glfw`) | auto |

strands_robots/simulation/mujoco/rendering.py

Lines changed: 65 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
"""Rendering mixin - render, render_depth, get_contacts, observation helpers."""
22

3-
import contextlib
43
import io
54
import logging
65
import os
7-
import tempfile
86
from pathlib import Path
97
from typing import TYPE_CHECKING, Any
108

@@ -14,14 +12,23 @@
1412
_ensure_mujoco,
1513
capture_stderr_fd,
1614
)
15+
from strands_robots.simulation.safe_output import (
16+
atomic_write_bytes,
17+
env_flag,
18+
resolve_sandbox_root,
19+
sanitize_name_component,
20+
validate_output_path,
21+
video_sandbox_args,
22+
)
1723

1824
logger = logging.getLogger(__name__)
1925

2026
# render(output_path=...) is an LLM-callable tool: the path is attacker-influenced.
2127
# Confine writes to a sandbox root, reject shell metacharacters / traversal /
2228
# symlinked targets, cap the payload size, and write atomically so a crash mid-write
23-
# cannot corrupt an existing file. See docs and the STRANDS_ROBOTS_RENDER_* env vars.
24-
_RENDER_PATH_BAD_CHARS = frozenset({";", "|", "$", "`", ">", "<", "\n", "\r", "\x00"})
29+
# cannot corrupt an existing file. The generic guards live in
30+
# strands_robots.simulation.safe_output; the render-specific sandbox + size cap
31+
# (STRANDS_ROBOTS_RENDER_* env vars) are bound here.
2532
_DEFAULT_MAX_RENDER_BYTES = 50 * 1024 * 1024 # 50 MB
2633

2734

@@ -31,8 +38,7 @@ def _render_sandbox_root() -> Path:
3138
Defaults to ``~/.strands_robots/renders``; override with the
3239
``STRANDS_ROBOTS_RENDER_ROOT`` env var.
3340
"""
34-
raw = os.getenv("STRANDS_ROBOTS_RENDER_ROOT") or str(Path.home() / ".strands_robots" / "renders")
35-
return Path(raw).expanduser().resolve(strict=False)
41+
return resolve_sandbox_root("STRANDS_ROBOTS_RENDER_ROOT", "renders")
3642

3743

3844
def _max_render_bytes() -> int:
@@ -50,77 +56,21 @@ def _max_render_bytes() -> int:
5056

5157

5258
def _validate_render_output_path(output_path: str) -> Path:
53-
"""Validate and resolve an LLM-supplied render output path.
54-
55-
Rejects shell metacharacters, backslash (Windows-style) separators, paths
56-
that resolve outside the render sandbox root, and symlinked targets. Returns
57-
the fully-resolved destination ``Path``.
58-
59-
Args:
60-
output_path: Caller-supplied destination path.
59+
"""Validate an LLM-supplied render path, confined to the render sandbox.
6160
62-
Returns:
63-
The resolved, sandbox-confined destination path.
61+
Thin render-specific binding over
62+
:func:`strands_robots.simulation.safe_output.validate_output_path`: absolute
63+
paths outside the sandbox are rejected unless ``STRANDS_ROBOTS_RENDER_ALLOW_ABS``
64+
opts in.
6465
6566
Raises:
6667
ValueError: If the path is unsafe (the caller maps this to a tool error).
6768
"""
68-
if any(b in output_path for b in _RENDER_PATH_BAD_CHARS):
69-
raise ValueError("unsafe output_path: shell metacharacters")
70-
# Backslash is not a POSIX separator, so "..\..\etc" would survive a
71-
# "/"-only traversal check. Reject it outright rather than guess intent.
72-
if "\\" in output_path:
73-
raise ValueError("unsafe output_path: backslash separators not allowed")
74-
if not output_path.strip():
75-
raise ValueError("unsafe output_path: empty")
76-
77-
raw = Path(output_path).expanduser()
78-
# Refuse to follow a symlink planted at the target (arbitrary-write vector).
79-
if raw.is_symlink():
80-
raise ValueError(f"output_path {output_path!r} is a symlink - refusing to follow")
81-
82-
# resolve() normalizes "..", expands the chain, and follows any intermediate
83-
# symlinks, so the sandbox check below sees the true on-disk destination.
84-
resolved = raw.resolve(strict=False)
85-
86-
allow_abs = (os.getenv("STRANDS_ROBOTS_RENDER_ALLOW_ABS") or "").strip().lower() in ("1", "true", "yes")
87-
if not allow_abs:
88-
root = _render_sandbox_root()
89-
try:
90-
resolved.relative_to(root)
91-
except ValueError as e:
92-
raise ValueError(
93-
f"output_path {resolved} is outside the render sandbox {root} "
94-
"(set STRANDS_ROBOTS_RENDER_ALLOW_ABS=1 to allow absolute paths)"
95-
) from e
96-
return resolved
97-
98-
99-
def _atomic_write_png(path: Path, data: bytes) -> None:
100-
"""Write ``data`` to ``path`` atomically with restrictive permissions.
101-
102-
Writes to a temp file in the destination directory then ``os.replace``s it
103-
into place, so a crash mid-write cannot truncate or corrupt an existing file
104-
at ``path``. Created directories are ``0o700`` and the final file is ``0o600``
105-
(owner-only; the render sandbox is private to the running user).
106-
"""
107-
parent = path.parent
108-
parent_existed = parent.exists()
109-
parent.mkdir(parents=True, exist_ok=True)
110-
if not parent_existed:
111-
with contextlib.suppress(OSError):
112-
os.chmod(parent, 0o700)
113-
114-
fd, tmp = tempfile.mkstemp(prefix=path.name + ".", suffix=".tmp", dir=parent)
115-
try:
116-
with os.fdopen(fd, "wb") as f:
117-
f.write(data)
118-
os.replace(tmp, path)
119-
except BaseException:
120-
with contextlib.suppress(OSError):
121-
os.unlink(tmp)
122-
raise
123-
os.chmod(path, 0o600)
69+
return validate_output_path(
70+
output_path,
71+
sandbox_root=_render_sandbox_root(),
72+
allow_abs=env_flag("STRANDS_ROBOTS_RENDER_ALLOW_ABS"),
73+
)
12474

12575

12676
def _save_render_png(output_path: str, png_bytes: bytes) -> str:
@@ -135,7 +85,7 @@ def _save_render_png(output_path: str, png_bytes: bytes) -> str:
13585
max_bytes = _max_render_bytes()
13686
if len(png_bytes) > max_bytes:
13787
raise ValueError(f"png is {len(png_bytes)} bytes, exceeds limit {max_bytes}")
138-
_atomic_write_png(safe, png_bytes)
88+
atomic_write_bytes(safe, png_bytes)
13989
return str(safe)
14090

14191

@@ -1237,10 +1187,13 @@ def start_cameras_recording(
12371187
12381188
Args:
12391189
cameras: list of camera names; None = every camera.
1240-
output_dir: where to write ``{tag}__{cam}.mp4``.
1190+
output_dir: where to write ``{tag}__{cam}.mp4``. Validated against
1191+
``..`` traversal / backslash / shell metacharacters / symlink;
1192+
set ``STRANDS_ROBOTS_VIDEO_ROOT`` to confine it to a sandbox.
12411193
fps: capture rate.
12421194
width/height: per-frame size.
1243-
name: filename tag (auto if None).
1195+
name: filename tag (auto if None). Validated as a single path
1196+
component - separators / traversal / metacharacters rejected.
12441197
max_frames_per_camera: safety cap on in-memory buffers.
12451198
"""
12461199
import os as _os
@@ -1272,7 +1225,22 @@ def start_cameras_recording(
12721225
if not names:
12731226
return {"status": "error", "content": [{"text": "No cameras to record."}]}
12741227

1275-
out_dir = _os.path.abspath(output_dir or _os.path.join(_tempfile.gettempdir(), "strands_robots", "recordings"))
1228+
# output_dir and name are LLM-supplied: reject traversal / symlink /
1229+
# metacharacters (and a name carrying path separators) before we
1230+
# makedirs and interpolate name into the per-camera filename.
1231+
# Confinement to a video sandbox is opt-in via STRANDS_ROBOTS_VIDEO_ROOT.
1232+
try:
1233+
if name is not None:
1234+
sanitize_name_component(name, label="name")
1235+
if output_dir is not None:
1236+
_sb_root, _allow_abs = video_sandbox_args()
1237+
out_dir = str(
1238+
validate_output_path(output_dir, sandbox_root=_sb_root, allow_abs=_allow_abs, label="output_dir")
1239+
)
1240+
else:
1241+
out_dir = _os.path.join(_tempfile.gettempdir(), "strands_robots", "recordings")
1242+
except ValueError as _e:
1243+
return {"status": "error", "content": [{"text": f"cameras_recording: {_e}"}]}
12761244
_os.makedirs(out_dir, exist_ok=True)
12771245
tag = name or f"rec_{_uuid.uuid4().hex[:8]}"
12781246

@@ -1594,12 +1562,16 @@ def start_cameras_recording_synchronous(
15941562
Args:
15951563
cameras: list of camera names; ``None`` = every camera.
15961564
output_dir: where to write ``{tag}__{cam}.mp4``. Defaults to
1597-
``$TMPDIR/strands_robots/recordings``.
1565+
``$TMPDIR/strands_robots/recordings``. Validated against ``..``
1566+
traversal / backslash / shell metacharacters / symlink; set
1567+
``STRANDS_ROBOTS_VIDEO_ROOT`` to confine it to a sandbox.
15981568
fps: encoded MP4 frame rate (and target capture rate when
15991569
``on_frame`` fires more often than ``fps``).
16001570
width, height: per-frame size; defaults to the renderer's
16011571
native resolution.
16021572
name: filename tag (auto-generated UUID prefix when ``None``).
1573+
Validated as a single path component - separators / traversal
1574+
/ metacharacters rejected.
16031575
max_frames_per_camera: safety cap on in-memory buffers.
16041576
Frames beyond the cap are silently dropped (status
16051577
visible via :meth:`get_cameras_recording_status`).
@@ -1639,7 +1611,22 @@ def start_cameras_recording_synchronous(
16391611
if not names:
16401612
return {"status": "error", "content": [{"text": "No cameras to record."}]}
16411613

1642-
out_dir = _os.path.abspath(output_dir or _os.path.join(_tempfile.gettempdir(), "strands_robots", "recordings"))
1614+
# output_dir and name are LLM-supplied: reject traversal / symlink /
1615+
# metacharacters (and a name carrying path separators) before we
1616+
# makedirs and interpolate name into the per-camera filename.
1617+
# Confinement to a video sandbox is opt-in via STRANDS_ROBOTS_VIDEO_ROOT.
1618+
try:
1619+
if name is not None:
1620+
sanitize_name_component(name, label="name")
1621+
if output_dir is not None:
1622+
_sb_root, _allow_abs = video_sandbox_args()
1623+
out_dir = str(
1624+
validate_output_path(output_dir, sandbox_root=_sb_root, allow_abs=_allow_abs, label="output_dir")
1625+
)
1626+
else:
1627+
out_dir = _os.path.join(_tempfile.gettempdir(), "strands_robots", "recordings")
1628+
except ValueError as _e:
1629+
return {"status": "error", "content": [{"text": f"cameras_recording: {_e}"}]}
16431630
_os.makedirs(out_dir, exist_ok=True)
16441631
tag = name or f"rec_{_uuid.uuid4().hex[:8]}"
16451632

strands_robots/simulation/policy_runner.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
from strands_robots.simulation.benchmark import BenchmarkProtocol
5252

5353
from strands_robots.simulation.models import TrajectoryStep
54+
from strands_robots.simulation.safe_output import validate_output_path, video_sandbox_args
5455

5556
logger = logging.getLogger(__name__)
5657

@@ -186,6 +187,10 @@ class VideoConfig:
186187
187188
Attributes:
188189
path: Output MP4 path. ``None``/empty string → recording disabled.
190+
LLM-supplied, so it is validated (no ``..`` traversal, backslash
191+
separators, shell metacharacters, or symlinked target) before a
192+
writer is opened; set ``STRANDS_ROBOTS_VIDEO_ROOT`` to confine it
193+
to a sandbox.
189194
fps: Frames per second to write.
190195
camera: Camera name to render from. ``None`` → backend default.
191196
width: Render width in pixels.
@@ -724,7 +729,17 @@ def _rtc_telemetry() -> dict[str, Any]:
724729
if video is not None and video.enabled:
725730
# video.enabled guarantees video.path is a non-empty str; narrow for mypy.
726731
assert video.path is not None
727-
video_path = video.path
732+
# video.path is LLM-supplied: reject shell metacharacters, backslash
733+
# separators, ".." traversal, and a symlinked target before we
734+
# makedirs + open a writer on it. Absolute paths stay allowed (the
735+
# historic contract); set STRANDS_ROBOTS_VIDEO_ROOT to sandbox them.
736+
_sb_root, _allow_abs = video_sandbox_args()
737+
try:
738+
video_path = str(
739+
validate_output_path(video.path, sandbox_root=_sb_root, allow_abs=_allow_abs, label="video path")
740+
)
741+
except ValueError as _e:
742+
return {"status": "error", "content": [{"text": f"video recording: {_e}"}]}
728743

729744
# Pre-validate the camera name ONCE before the step loop. This
730745
# surfaces "camera not found" as a clean up-front error rather

0 commit comments

Comments
 (0)