Root cause
Injection & unsafe execution introduced
fix: managed agents sandbox security (fixes #1426)
How AI contributed: Direct introductionPublished Jun 18, 2026 in
mervinpraison/praisonaiAll 19 findings in this repository
PraisonAI's compute bridge turned file tools into shell command strings. In src/praisonai/praisonai/integrations/managed_local.py, _bridge_file_tool() substituted the caller's path into cat, ls -la, and heredoc templates before asyncio.create_subprocess_shell or sh -c ran them. Because the path was never escaped, a value containing a double quote broke out of the quoted argument and ran arbitrary commands in the compute environment, and the approval registry left read_file and list_files outside the critical-command review. Version 4.6.59 replaced the raw interpolation with shlex.quote().
_bridge_file_tool() built each command by pasting the file path into a double-quoted template such as the cat and ls -la commands, without escaping or validating the value. A path that contained a double quote closed the quoted argument early, so the remaining text was parsed as shell syntax and executed by the compute provider.
Root cause
fix: managed agents sandbox security (fixes #1426)
Fix
The fix shell-quotes file and directory path arguments before constructing the compute-provider cat and ls commands, closing the quoted-path breakout demonstrated by this case.
Fix by MervinPraison · no AI marker found
Case facts
@@ -78,11 +78,11 @@ class LocalManagedConfig: metadata: Dict[str, Any] = field(default_factory=dict) # ── Environment fields ──- sandbox_type: str = "subprocess" working_dir: str = "" env: Dict[str, str] = field(default_factory=dict) packages: Optional[Dict[str, List[str]]] = None networking: Dict[str, Any] = field(default_factory=lambda: {"type": "unrestricted"})+ host_packages_ok: bool = False # Allow packages on host (security opt-out) # ── Session fields ── session_title: str = "PraisonAI local session"@@ -288,7 +288,12 @@ def _resolve_model(self) -> str: # Tool resolution # ------------------------------------------------------------------ def _resolve_tools(self) -> List:- """Resolve tool names to actual PraisonAI tool functions."""+ """Resolve tool names to actual PraisonAI tool functions.+ + When a compute provider is attached, shell-based tools (execute_command,+ read_file, write_file, list_files) are bridged to run in the compute+ environment instead of on the host.+ """ raw_tools = self._cfg.get("tools", list(_DEFAULT_TOOLS)) tool_names = _translate_anthropic_tools(raw_tools) @@ -299,12 +304,18 @@ def _resolve_tools(self) -> List: # Import tools lazily tools = []+ compute_bridged_tools = {"execute_command", "read_file", "write_file", "list_files"}+ for name in resolved_names: try: from praisonaiagents import tools as tool_module func = getattr(tool_module, name, None) if func is not None:- tools.append(func)+ # Bridge shell-based tools to compute when available+ if self._compute and name in compute_bridged_tools:+ tools.append(self._create_compute_bridge_tool(name, func))+ else:+ tools.append(func) else: logger.warning("[local_managed] tool not found: %s", name) except Exception as e:@@ -327,6 +338,119 @@ def _resolve_tools(self) -> List: return tools + def _create_compute_bridge_tool(self, tool_name: str, original_func: Callable) -> Callable:+ """Create a compute-bridged version of a tool.+ + Wraps the original tool function to execute commands in the compute+ environment instead of on the host.+ """+ import inspect+ import asyncio+ + def compute_bridged_tool(*args, **kwargs):+ """Compute-bridged tool wrapper."""+ # Auto-provision compute if needed+ if self._compute_instance_id is None:+ try:+ loop = asyncio.get_event_loop()+ loop.run_until_complete(self.provision_compute())+ except RuntimeError:+ asyncio.run(self.provision_compute())+ + if tool_name == "execute_command":+ # For execute_command, directly route to compute+ command = args[0] if args else kwargs.get("command", "")+ if not command:+ return "Error: No command specified"+ + try:+ try:+ loop = asyncio.get_event_loop()+ result = loop.run_until_complete(+ self._compute.execute(self._compute_instance_id, command)+ )+ except RuntimeError:+ result = asyncio.run(+ self._compute.execute(self._compute_instance_id, command)+ )+ + # Format result similar to local execute_command+ if result.get("exit_code", 0) == 0:+ return result.get("stdout", "")@@ -53,9 +53,9 @@ def test_defaults(self): cfg = LocalManagedConfig() assert cfg.name == "Agent" assert cfg.model == "gpt-4o"- assert cfg.sandbox_type == "subprocess" assert cfg.max_turns == 25 assert "execute_command" in cfg.tools+ assert cfg.host_packages_ok is False def test_custom_config(self): from praisonai.integrations.managed_local import LocalManagedConfig@@ -353,6 +353,214 @@ def test_packages_in_config(self): cfg = LocalManagedConfig(packages={"pip": ["pandas", "numpy"]}) assert cfg.packages == {"pip": ["pandas", "numpy"]} + def test_host_packages_ok_default_false(self):+ from praisonai.integrations.managed_local import LocalManagedConfig+ cfg = LocalManagedConfig()+ assert cfg.host_packages_ok is False++ def test_host_packages_ok_explicit_true(self):+ from praisonai.integrations.managed_local import LocalManagedConfig+ cfg = LocalManagedConfig(host_packages_ok=True)+ assert cfg.host_packages_ok is True+++class TestManagedSandboxSafety:+ """Test security features for managed agents package installation."""++ def test_install_packages_without_compute_raises(self):+ """Test that package installation without compute provider raises ManagedSandboxRequired."""+ import pytest+ from praisonai.integrations.managed_local import LocalManagedAgent, LocalManagedConfig+ from praisonai.integrations.managed_agents import ManagedSandboxRequired+ + cfg = LocalManagedConfig(packages={"pip": ["requests"]})+ agent = LocalManagedAgent(config=cfg)+ + with pytest.raises(ManagedSandboxRequired) as exc_info:+ agent._install_packages()+ + assert "Package installation requested" in str(exc_info.value)+ assert "security risk" in str(exc_info.value)+ assert "compute='docker'" in str(exc_info.value)+ assert "host_packages_ok=True" in str(exc_info.value)++ def test_install_packages_with_host_packages_ok_succeeds(self):+ """Test that package installation with host_packages_ok=True succeeds."""+ from unittest.mock import patch+ from praisonai.integrations.managed_local import LocalManagedAgent, LocalManagedConfig+ + cfg = LocalManagedConfig(packages={"pip": ["requests"]}, host_packages_ok=True)+ agent = LocalManagedAgent(config=cfg)+ + with patch('praisonai.integrations.managed_local.subprocess.run') as mock_run:+ mock_run.return_value = None+ agent._install_packages() # Should not raise+ mock_run.assert_called_once()++ def test_install_packages_with_compute_uses_sandbox(self):+ """Test that package installation with compute provider uses sandbox."""+ from unittest.mock import AsyncMock, patch+ from praisonai.integrations.managed_local import LocalManagedAgent, LocalManagedConfig+ + cfg = LocalManagedConfig(packages={"pip": ["requests"]})+ agent = LocalManagedAgent(config=cfg, compute="local")+ + # Mock the compute execution+ with patch.object(agent, 'provision_compute') as mock_provision, \+ patch.object(agent._compute, 'execute') as mock_execute, \+ patch('asyncio.run') as mock_asyncio_run, \+ patch('asyncio.get_event_loop') as mock_get_loop:+ + mock_provision.return_value = None+ mock_execute.return_value = {"exit_code": 0, "stdout": "installed"}+ agent._compute_instance_id = "test_instance"+ mock_asyncio_run.return_value = {"exit_code": 0, "stdout": "installed"}+ + agent._install_packages()+ + # Verify subprocess.run was NOT called (no host installation)+ with patch('praisonai.integrations.managed_local.subprocess.run') as mock_run:+ agent._install_packages()+ mock_run.assert_not_called()++ def test_no_packages_no_error(self):+ """Test that agents without packages work normally."""+ from praisonai.integrations.managed_local import LocalManagedAgent+ agent = LocalManagedAgent()+ agent._install_packages() # Should not raise@@ -36,6 +36,24 @@ logger = logging.getLogger(__name__) +# ---------------------------------------------------------------------------+# Shared exceptions+# ---------------------------------------------------------------------------++class ManagedSandboxRequired(RuntimeError):+ """Raised when package installation is attempted without proper sandboxing.+ + This exception is raised when `LocalManagedAgent` is configured with packages+ but no compute provider is specified, creating a security risk where packages+ would be installed on the host system.+ + To fix this error, either:+ 1. Specify a compute provider: `LocalManagedAgent(compute="docker", ...)`+ 2. Explicitly allow host packages: `LocalManagedConfig(host_packages_ok=True)`+ """+ pass++ # --------------------------------------------------------------------------- # ManagedConfig — Anthropic-specific configuration dataclass # Lives in the Wrapper (not Core SDK) because its fields map directly to@@ -404,22 +404,21 @@ def _bridge_file_tool(self, tool_name: str, *args, **kwargs) -> str: filepath = args[0] if args else kwargs.get("filepath", "") if not filepath: return "Error: No filepath specified"- - command = f'cat "{filepath}"'+ import shlex+ command = f"cat {shlex.quote(filepath)}" elif tool_name == "write_file": filepath = args[0] if args else kwargs.get("filepath", "") content = args[1] if len(args) > 1 else kwargs.get("content", "") if not filepath: return "Error: No filepath specified"- - # Escape content for shell import shlex- command = f'cat > "{filepath}" << "EOF"\n{content}\nEOF'+ command = f'cat > {shlex.quote(filepath)} << "EOF"\n{content}\nEOF' elif tool_name == "list_files": directory = args[0] if args else kwargs.get("directory", ".")- command = f'ls -la "{directory}"'+ import shlex+ command = f"ls -la {shlex.quote(directory)}" else: return f"Error: Unsupported bridged tool: {tool_name}"AI-assisted change 7aec9d87ce2951f0055ea421b176f0c557365fdcbf3c3d27603651c23aaeb77a · Fix c3068197b3bd9d9d58ed322d4200e6d115ab2d98e08f59c5971a96eb761a3d04