diff --git a/tests/tools/test_tts_path_traversal.py b/tests/tools/test_tts_path_traversal.py new file mode 100644 index 00000000000..e6b20d817c0 --- /dev/null +++ b/tests/tools/test_tts_path_traversal.py @@ -0,0 +1,60 @@ +"""Regression: text_to_speech_tool output_path must reject '..' traversal. + +The TTS surface accepts agent/user-supplied absolute paths (writing to a +chosen file is the whole point). What it must reject is paths that use +``..`` components to escape their declared base — those are almost +always either a bug or prompt-injection-controlled +(e.g. ``output_path="audio/../../etc/cron.d/x"``). +""" + +import json + +from tools.tts_tool import text_to_speech_tool + + +def test_output_path_rejects_traversal_escape(): + """A path with '..' components must be rejected before any provider work.""" + result = json.loads(text_to_speech_tool( + text="hello", + output_path="audio/../../etc/cron.d/malicious", + )) + assert result["success"] is False + assert "traversal" in result["error"].lower() + + +def test_output_path_rejects_bare_dotdot(): + """Bare '..' prefix must be rejected.""" + result = json.loads(text_to_speech_tool( + text="hello", + output_path="../escape.mp3", + )) + assert result["success"] is False + assert "traversal" in result["error"].lower() + + +def test_output_path_absolute_path_passes_guard(tmp_path, monkeypatch): + """Explicit absolute paths must pass the traversal guard. + + The agent legitimately writes audio to user-specified absolute paths; + only ``..`` components are rejected. Any subsequent failure (no + provider configured, etc.) is fine — the assertion is specifically + that the 'traversal' rejection didn't fire. + """ + inside = tmp_path / "clip.mp3" + result = json.loads(text_to_speech_tool( + text="hello", + output_path=str(inside), + )) + error = result.get("error", "") + assert "traversal" not in error.lower() + + +def test_output_path_relative_no_dotdot_passes_guard(tmp_path, monkeypatch): + """Relative paths without '..' components must pass the guard.""" + monkeypatch.chdir(tmp_path) + result = json.loads(text_to_speech_tool( + text="hello", + output_path="subdir/clip.mp3", + )) + error = result.get("error", "") + assert "traversal" not in error.lower() diff --git a/tools/tts_tool.py b/tools/tts_tool.py index b1dc14f85aa..0c0c7bd203c 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -1868,6 +1868,24 @@ def text_to_speech_tool( # Determine output path if output_path: + # Reject '..' traversal components in the user-supplied path. An + # explicit absolute path is fine (the agent legitimately writes + # audio to user-specified locations), but a path that uses ``..`` + # to escape its declared base is almost always either a bug or + # prompt-injection-controlled — e.g. + # ``output_path="audio/../../etc/cron.d/x"``. The terminal tool + # can still write anywhere with approval; this just keeps the + # unattended TTS surface from materializing files via traversal. + from tools.path_security import has_traversal_component + if has_traversal_component(output_path): + return json.dumps({ + "success": False, + "error": ( + f"output_path contains '..' traversal component: " + f"{output_path}. Use an absolute path or one relative " + "to the current directory without '..'." + ), + }, ensure_ascii=False) file_path = Path(output_path).expanduser() if command_provider_config is not None: # Respect caller-supplied path but align the extension with the