Skip to content

Commit 65728fa

Browse files
fix: resolve 3 runtime showstoppers in motion graphics module
🔴 Fix AgentTeam instantiation error - Change from leader= to process='hierarchical' with manager_llm= - Remove unused protocols import in team.py 🔴 Implement proper workspace safety validation - Add base_dir parameter to HtmlRenderBackend constructor - Implement real containment check using Path.relative_to() - Validate against base directory and temp directories - Reject path traversal attempts 🔴 Wire render_iterate into bounded retry system - Add max_retries parameter to RenderTools - Implement retry logic in render_composition() - Add render_with_bounded_retries() method exposing render_iterate - Fix workspace path mismatch (coordinator expects /renders) 🔧 Minor cleanups - Remove dead code in git_tools.py _validate_file_path - Align render workspace paths between coordinator and animator 🧪 Add comprehensive unit tests - test_backend_safety.py for workspace containment validation - test_render_retries.py for bounded retry functionality Fixes #27 Co-authored-by: Mervin Praison <MervinPraison@users.noreply.github.com>
1 parent d5d0442 commit 65728fa

File tree

6 files changed

+278
-24
lines changed

6 files changed

+278
-24
lines changed

praisonai_tools/tools/git_tools.py

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -447,17 +447,7 @@ def _validate_file_path(self, file_path: str) -> str:
447447
if ".." in safe_path:
448448
raise ValueError("Invalid file path")
449449

450-
# Additional safety: resolve path and check it doesn't escape
451-
try:
452-
from pathlib import Path
453-
resolved = Path(safe_path).resolve()
454-
# If it tries to go outside current dir, reject
455-
if str(resolved).startswith("/") and not str(resolved).startswith(str(Path.cwd())):
456-
# This is an absolute path that escapes - but we allow relative paths
457-
pass
458-
except Exception:
459-
# If path resolution fails, reject
460-
raise ValueError("Invalid file path")
450+
# Basic path validation - above checks already prevent most traversal attacks
461451

462452
return safe_path
463453

praisonai_tools/video/motion_graphics/agent.py

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,10 @@
2020
class RenderTools:
2121
"""Tools for motion graphics rendering."""
2222

23-
def __init__(self, backend: RenderBackendProtocol, workspace: Path):
23+
def __init__(self, backend: RenderBackendProtocol, workspace: Path, max_retries: int = 3):
2424
self.backend = backend
2525
self.workspace = workspace
26+
self.max_retries = max_retries
2627

2728
async def lint_composition(self, strict: bool = False) -> dict:
2829
"""Lint the motion graphics composition.
@@ -46,7 +47,7 @@ async def render_composition(
4647
fps: int = 30,
4748
quality: str = "standard"
4849
) -> dict:
49-
"""Render the motion graphics composition to MP4.
50+
"""Render the motion graphics composition to MP4 with bounded retries.
5051
5152
Args:
5253
output_name: Output filename
@@ -64,7 +65,67 @@ async def render_composition(
6465
quality=quality
6566
)
6667

68+
# Direct render with bounded retries for transient failures
69+
# Note: For composition authoring retries, the agent should use render_iterate
70+
# with appropriate write/lint/patch functions
6771
result = await self.backend.render(self.workspace, opts)
72+
attempts = 1
73+
74+
while not result.ok and attempts < self.max_retries:
75+
# Retry for transient failures (e.g., ffmpeg timeouts)
76+
result = await self.backend.render(self.workspace, opts)
77+
attempts += 1
78+
79+
return {
80+
"ok": result.ok,
81+
"output_path": str(result.output_path) if result.output_path else None,
82+
"size_kb": result.size_kb,
83+
"stderr": result.stderr,
84+
"attempts": attempts,
85+
"bytes": result.bytes_
86+
}
87+
88+
async def render_with_bounded_retries(
89+
self,
90+
write_fn,
91+
patch_fn,
92+
output_name: str = "video.mp4",
93+
fps: int = 30,
94+
quality: str = "standard",
95+
**kwargs
96+
) -> dict:
97+
"""Use render_iterate for full composition authoring with retries.
98+
99+
Args:
100+
write_fn: Function to write initial composition
101+
patch_fn: Function to patch composition based on error
102+
output_name: Output filename
103+
fps: Frames per second
104+
quality: Quality setting
105+
**kwargs: Arguments passed to write_fn
106+
107+
Returns:
108+
Dict with render results
109+
"""
110+
from ._render_loop import render_iterate
111+
from .protocols import RenderOpts
112+
113+
opts = RenderOpts(output_name=output_name, fps=fps, quality=quality)
114+
115+
async def lint_fn():
116+
return await self.backend.lint(self.workspace, strict=False)
117+
118+
async def render_fn():
119+
return await self.backend.render(self.workspace, opts)
120+
121+
result = await render_iterate(
122+
write_fn=write_fn,
123+
lint_fn=lint_fn,
124+
render_fn=render_fn,
125+
patch_fn=patch_fn,
126+
max_retries=self.max_retries,
127+
**kwargs
128+
)
68129

69130
return {
70131
"ok": result.ok,
@@ -134,7 +195,7 @@ def create_motion_graphics_agent(
134195

135196
# Create tools
136197
file_tools = FileTools(base_dir=str(workspace))
137-
render_tools = RenderTools(render_backend, workspace)
198+
render_tools = RenderTools(render_backend, workspace, max_retries)
138199

139200
# Create agent
140201
agent = Agent(

praisonai_tools/video/motion_graphics/backend_html.py

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import asyncio
44
import json
5+
import os
56
import shutil
67
import subprocess
78
import tempfile
@@ -36,7 +37,7 @@ class HtmlRenderBackend:
3637
- Subprocess timeout limits
3738
"""
3839

39-
def __init__(self):
40+
def __init__(self, base_dir: Optional[Path] = None):
4041
if async_playwright is None:
4142
raise ImportError(
4243
"Playwright not installed. Install with: pip install playwright"
@@ -45,6 +46,13 @@ def __init__(self):
4546
raise ImportError(
4647
"imageio-ffmpeg not installed. Install with: pip install imageio-ffmpeg"
4748
)
49+
50+
# Set allowed base directory for workspace safety
51+
if base_dir is None:
52+
# Default to current working directory and temp directory as allowed roots
53+
self._allowed_base = Path.cwd()
54+
else:
55+
self._allowed_base = Path(base_dir).resolve()
4856

4957
async def lint(self, workspace: Path, strict: bool = False) -> LintResult:
5058
"""Lint HTML composition for common issues."""
@@ -128,10 +136,29 @@ async def render(self, workspace: Path, opts: RenderOpts) -> RenderResult:
128136
def _is_safe_workspace(self, workspace: Path) -> bool:
129137
"""Check if workspace path is safe (prevents path traversal)."""
130138
try:
131-
workspace_abs = workspace.resolve()
132-
# Basic check - workspace should be under a temp directory or user's project
133-
return True # Add more sophisticated checks as needed
134-
except Exception:
139+
workspace_abs = workspace.resolve(strict=True)
140+
allowed_base_abs = self._allowed_base.resolve()
141+
142+
# Also allow temp directories
143+
temp_base = Path(tempfile.gettempdir()).resolve()
144+
145+
# Check if workspace is under allowed base or temp directory
146+
try:
147+
# Check if workspace is relative to allowed base
148+
workspace_abs.relative_to(allowed_base_abs)
149+
return True
150+
except ValueError:
151+
pass
152+
153+
try:
154+
# Check if workspace is relative to temp directory
155+
workspace_abs.relative_to(temp_base)
156+
return True
157+
except ValueError:
158+
pass
159+
160+
return False
161+
except (OSError, ValueError):
135162
return False
136163

137164
async def _render_with_playwright(self, workspace: Path, opts: RenderOpts) -> RenderResult:

praisonai_tools/video/motion_graphics/team.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
search_web = None
1616

1717
from .agent import create_motion_graphics_agent
18-
from ..motion_graphics import protocols
1918

2019

2120
def motion_graphics_team(
@@ -58,6 +57,8 @@ def motion_graphics_team(
5857
workspace = Path(workspace)
5958

6059
workspace.mkdir(parents=True, exist_ok=True)
60+
renders_dir = workspace / "renders"
61+
renders_dir.mkdir(parents=True, exist_ok=True)
6162

6263
# Create agents
6364
agents = []
@@ -74,7 +75,7 @@ def motion_graphics_team(
7475
4. Return final results to users
7576
7677
CRITICAL OUTPUT VALIDATION RULES:
77-
- A render succeeded ONLY IF the reply contains a concrete file path (e.g., '/renders/video_123.mp4') AND no error indicators
78+
- A render succeeded ONLY IF the reply contains a concrete file path under '{renders_dir}' AND no error indicators
7879
- Never fabricate file paths or claim success without concrete evidence
7980
- Surface all errors from the Animator
8081
- Stop work after maximum retry budget is exceeded
@@ -137,16 +138,17 @@ def motion_graphics_team(
137138
# Animator - the core specialist
138139
animator = create_motion_graphics_agent(
139140
backend=backend,
140-
workspace=workspace / "animations",
141+
workspace=renders_dir,
141142
llm=llm
142143
)
143144
animator.name = "animator"
144145
agents.append(animator)
145146

146-
# Create team with coordinator as leader
147+
# Create team with coordinator as manager via hierarchical process
147148
team = AgentTeam(
148149
agents=agents,
149-
leader=coordinator,
150+
process="hierarchical",
151+
manager_llm=llm,
150152
**team_kwargs
151153
)
152154

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""Tests for backend workspace safety."""
2+
3+
import pytest
4+
import tempfile
5+
from pathlib import Path
6+
from unittest.mock import Mock, AsyncMock
7+
8+
try:
9+
from praisonai_tools.video.motion_graphics.backend_html import HtmlRenderBackend
10+
from praisonai_tools.video.motion_graphics.protocols import RenderOpts
11+
BACKEND_AVAILABLE = True
12+
except ImportError:
13+
BACKEND_AVAILABLE = False
14+
15+
16+
@pytest.mark.skipif(not BACKEND_AVAILABLE, reason="Motion graphics backend not available")
17+
class TestBackendSafety:
18+
"""Test workspace safety features."""
19+
20+
def setup_method(self):
21+
"""Set up test fixtures."""
22+
self.temp_dir = Path(tempfile.mkdtemp())
23+
self.backend = HtmlRenderBackend(base_dir=self.temp_dir)
24+
25+
def teardown_method(self):
26+
"""Clean up test fixtures."""
27+
import shutil
28+
shutil.rmtree(self.temp_dir, ignore_errors=True)
29+
30+
def test_safe_workspace_under_base_dir(self):
31+
"""Test workspace under base directory is considered safe."""
32+
safe_workspace = self.temp_dir / "workspace"
33+
safe_workspace.mkdir()
34+
35+
assert self.backend._is_safe_workspace(safe_workspace)
36+
37+
def test_unsafe_workspace_outside_base_dir(self):
38+
"""Test workspace outside base directory is rejected."""
39+
# Try to escape to parent directory
40+
unsafe_workspace = self.temp_dir.parent / "escaped"
41+
42+
assert not self.backend._is_safe_workspace(unsafe_workspace)
43+
44+
def test_workspace_in_temp_dir_allowed(self):
45+
"""Test workspace in temp directory is allowed."""
46+
import tempfile
47+
temp_workspace = Path(tempfile.gettempdir()) / "test_workspace"
48+
49+
assert self.backend._is_safe_workspace(temp_workspace)
50+
51+
def test_nonexistent_workspace_rejected(self):
52+
"""Test non-existent workspace is rejected."""
53+
nonexistent = Path("/does/not/exist/workspace")
54+
55+
assert not self.backend._is_safe_workspace(nonexistent)
56+
57+
@pytest.mark.asyncio
58+
async def test_unsafe_workspace_blocks_render(self):
59+
"""Test unsafe workspace blocks render operation."""
60+
unsafe_workspace = Path("/etc") # System directory
61+
opts = RenderOpts(output_name="test.mp4", fps=30, quality="standard")
62+
63+
result = await self.backend.render(unsafe_workspace, opts)
64+
65+
assert not result.ok
66+
assert "Unsafe workspace path" in result.stderr

0 commit comments

Comments
 (0)