11"""Rendering mixin - render, render_depth, get_contacts, observation helpers."""
22
3- import contextlib
43import io
54import logging
65import os
7- import tempfile
86from pathlib import Path
97from typing import TYPE_CHECKING , Any
108
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
1824logger = 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
3844def _max_render_bytes () -> int :
@@ -50,77 +56,21 @@ def _max_render_bytes() -> int:
5056
5157
5258def _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
12676def _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
0 commit comments