From 99d0bd96dc34fd42e06b1b795398ac51f79ca56a Mon Sep 17 00:00:00 2001 From: "praisonai-triage-agent[bot]" <272766704+praisonai-triage-agent[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:22:23 +0000 Subject: [PATCH 1/2] fix: managed agents sandbox security (fixes #1426) Add ManagedSandboxRequired exception for package installation safety Modify LocalManagedAgent to use compute providers for secure execution Implement compute-based tool execution routing for shell commands Add host_packages_ok safety opt-out flag for developer workflows Remove unused sandbox_type config field Add comprehensive tests for security functionality Security improvements: - Packages install in sandbox when compute provider attached - Raise exception when packages specified without compute/opt-out - Route execute_command, read_file, write_file, list_files through compute - Maintain backward compatibility with explicit opt-out flag Co-authored-by: MervinPraison --- .../tests/managed/test_managed_factory.py | 210 +++++++++++++++++- .../praisonai/integrations/managed_agents.py | 18 ++ .../praisonai/integrations/managed_local.py | 201 ++++++++++++++++- 3 files changed, 422 insertions(+), 7 deletions(-) diff --git a/src/praisonai-agents/tests/managed/test_managed_factory.py b/src/praisonai-agents/tests/managed/test_managed_factory.py index 782a348bd..7a8aaf9e8 100644 --- a/src/praisonai-agents/tests/managed/test_managed_factory.py +++ b/src/praisonai-agents/tests/managed/test_managed_factory.py @@ -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 + + def test_empty_packages_no_error(self): + """Test that empty packages dict works normally.""" + from praisonai.integrations.managed_local import LocalManagedConfig, LocalManagedAgent + cfg = LocalManagedConfig(packages={"pip": []}) + agent = LocalManagedAgent(config=cfg) + agent._install_packages() # Should not raise + + +class TestComputeToolBridge: + """Test compute-based tool execution routing.""" + + def test_bridged_tools_created_when_compute_attached(self): + """Test that shell-based tools are bridged when compute is attached.""" + from praisonai.integrations.managed_local import LocalManagedAgent + agent = LocalManagedAgent(compute="local") + tools = agent._resolve_tools() + + # Should have tools but they should be wrapped/bridged versions + tool_names = [getattr(t, '__name__', str(t)) for t in tools if callable(t)] + assert "execute_command" in tool_names + + def test_non_bridged_tools_use_original_when_no_compute(self): + """Test that tools use original implementation when no compute.""" + from praisonai.integrations.managed_local import LocalManagedAgent + agent = LocalManagedAgent() + tools = agent._resolve_tools() + + # Should have original tools + tool_names = [getattr(t, '__name__', str(t)) for t in tools if callable(t)] + assert "execute_command" in tool_names + + def test_compute_bridge_tool_execute_command(self): + """Test that execute_command is properly bridged to compute.""" + from unittest.mock import AsyncMock, patch + from praisonai.integrations.managed_local import LocalManagedAgent + + agent = LocalManagedAgent(compute="local") + agent._compute_instance_id = "test_instance" + + # Create a bridge tool for execute_command + original_func = lambda command: "original result" + bridge_tool = agent._create_compute_bridge_tool("execute_command", original_func) + + with patch.object(agent._compute, 'execute') as mock_execute, \ + patch('asyncio.run') as mock_asyncio_run: + + mock_asyncio_run.return_value = {"exit_code": 0, "stdout": "compute result"} + + result = bridge_tool("echo hello") + assert result == "compute result" + + # Verify it attempted to run in compute, not locally + mock_asyncio_run.assert_called() + + def test_compute_bridge_tool_read_file(self): + """Test that read_file is properly bridged to compute.""" + from unittest.mock import patch + from praisonai.integrations.managed_local import LocalManagedAgent + + agent = LocalManagedAgent(compute="local") + agent._compute_instance_id = "test_instance" + + original_func = lambda filepath: "original content" + bridge_tool = agent._create_compute_bridge_tool("read_file", original_func) + + with patch.object(agent, '_bridge_file_tool') as mock_bridge: + mock_bridge.return_value = "file content from compute" + + result = bridge_tool("/path/to/file") + assert result == "file content from compute" + mock_bridge.assert_called_once_with("read_file", "/path/to/file") + + def test_compute_bridge_tool_write_file(self): + """Test that write_file is properly bridged to compute.""" + from unittest.mock import patch + from praisonai.integrations.managed_local import LocalManagedAgent + + agent = LocalManagedAgent(compute="local") + agent._compute_instance_id = "test_instance" + + original_func = lambda filepath, content: "written locally" + bridge_tool = agent._create_compute_bridge_tool("write_file", original_func) + + with patch.object(agent, '_bridge_file_tool') as mock_bridge: + mock_bridge.return_value = "written to compute" + + result = bridge_tool("/path/to/file", "content") + assert result == "written to compute" + mock_bridge.assert_called_once_with("write_file", "/path/to/file", "content") + + def test_bridge_file_tool_read(self): + """Test _bridge_file_tool for read operations.""" + from unittest.mock import patch + from praisonai.integrations.managed_local import LocalManagedAgent + + agent = LocalManagedAgent(compute="local") + agent._compute_instance_id = "test_instance" + + with patch('asyncio.run') as mock_asyncio_run: + mock_asyncio_run.return_value = {"exit_code": 0, "stdout": "file contents"} + + result = agent._bridge_file_tool("read_file", "/test/file") + assert result == "file contents" + + def test_bridge_file_tool_write(self): + """Test _bridge_file_tool for write operations.""" + from unittest.mock import patch + from praisonai.integrations.managed_local import LocalManagedAgent + + agent = LocalManagedAgent(compute="local") + agent._compute_instance_id = "test_instance" + + with patch('asyncio.run') as mock_asyncio_run: + mock_asyncio_run.return_value = {"exit_code": 0, "stdout": ""} + + result = agent._bridge_file_tool("write_file", "/test/file", "content") + assert result == "" + + def test_bridge_file_tool_list(self): + """Test _bridge_file_tool for list operations.""" + from unittest.mock import patch + from praisonai.integrations.managed_local import LocalManagedAgent + + agent = LocalManagedAgent(compute="local") + agent._compute_instance_id = "test_instance" + + with patch('asyncio.run') as mock_asyncio_run: + mock_asyncio_run.return_value = {"exit_code": 0, "stdout": "file1\nfile2\n"} + + result = agent._bridge_file_tool("list_files", "/test/dir") + assert result == "file1\nfile2\n" + class TestUpdateAgentKeepsSession: def test_update_preserves_session(self): diff --git a/src/praisonai/praisonai/integrations/managed_agents.py b/src/praisonai/praisonai/integrations/managed_agents.py index 761384abc..8af68beb5 100644 --- a/src/praisonai/praisonai/integrations/managed_agents.py +++ b/src/praisonai/praisonai/integrations/managed_agents.py @@ -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 diff --git a/src/praisonai/praisonai/integrations/managed_local.py b/src/praisonai/praisonai/integrations/managed_local.py index dc27489fc..3c3104d92 100644 --- a/src/praisonai/praisonai/integrations/managed_local.py +++ b/src/praisonai/praisonai/integrations/managed_local.py @@ -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", "") + else: + return f"Command failed (exit {result.get('exit_code', 1)}): {result.get('stderr', '')}" + + except Exception as e: + return f"Compute execution error: {e}" + + elif tool_name in {"read_file", "write_file", "list_files"}: + # For file operations, convert to shell commands in compute + return self._bridge_file_tool(tool_name, *args, **kwargs) + + else: + # Fallback to original tool (shouldn't happen for bridged tools) + return original_func(*args, **kwargs) + + # Copy function metadata + compute_bridged_tool.__name__ = tool_name + compute_bridged_tool.__doc__ = original_func.__doc__ or f"Compute-bridged {tool_name}" + + # Copy signature if available + try: + compute_bridged_tool.__signature__ = inspect.signature(original_func) + compute_bridged_tool.__annotations__ = getattr(original_func, "__annotations__", {}) + except (ValueError, TypeError): + pass + + return compute_bridged_tool + + def _bridge_file_tool(self, tool_name: str, *args, **kwargs) -> str: + """Bridge file operations to compute environment.""" + import asyncio + + if tool_name == "read_file": + filepath = args[0] if args else kwargs.get("filepath", "") + if not filepath: + return "Error: No filepath specified" + + command = f'cat "{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' + + elif tool_name == "list_files": + directory = args[0] if args else kwargs.get("directory", ".") + command = f'ls -la "{directory}"' + + else: + return f"Error: Unsupported bridged tool: {tool_name}" + + 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) + ) + + if result.get("exit_code", 0) == 0: + return result.get("stdout", "") + else: + return f"Command failed (exit {result.get('exit_code', 1)}): {result.get('stderr', '')}" + + except Exception as e: + return f"Compute execution error: {e}" + # ------------------------------------------------------------------ # Inner agent (lazy) # ------------------------------------------------------------------ @@ -451,21 +575,86 @@ def _restore_state(self) -> None: self.provider = saved_cfg["provider"] def _install_packages(self) -> None: - """Install packages specified in config before agent starts.""" + """Install packages specified in config before agent starts. + + Security behavior: + - If compute provider is attached: installs in sandbox via compute.execute() + - If no compute and host_packages_ok=True: installs on host (developer mode) + - If no compute and host_packages_ok=False: raises ManagedSandboxRequired + """ packages = self._cfg.get("packages") if not packages: return pip_pkgs = packages.get("pip", []) if isinstance(packages, dict) else [] - if pip_pkgs: + if not pip_pkgs: + return + + # Security check: require compute provider OR explicit opt-out + if self._compute is None: + host_packages_ok = self._cfg.get("host_packages_ok", False) + if not host_packages_ok: + from .managed_agents import ManagedSandboxRequired + raise ManagedSandboxRequired( + f"Package installation requested ({pip_pkgs}) but no compute provider specified. " + f"This would install packages on the host system, creating a security risk. " + f"Either specify a compute provider (compute='docker') or explicitly allow " + f"host packages with LocalManagedConfig(host_packages_ok=True)." + ) + + # Host installation (explicit opt-out) cmd = [sys.executable, "-m", "pip", "install", "-q"] + pip_pkgs - logger.info("[local_managed] installing pip packages: %s", pip_pkgs) + logger.warning("[local_managed] installing pip packages on HOST system: %s", pip_pkgs) try: subprocess.run(cmd, check=True, capture_output=True, timeout=120) + logger.info("[local_managed] host pip install completed") except subprocess.CalledProcessError as e: logger.warning("[local_managed] pip install failed: %s", e.stderr) except subprocess.TimeoutExpired: logger.warning("[local_managed] pip install timed out") + else: + # Sandbox installation via compute provider + self._install_packages_in_compute(pip_pkgs) + + def _install_packages_in_compute(self, pip_pkgs: List[str]) -> None: + """Install pip packages in the compute environment.""" + if not self._compute: + raise RuntimeError("No compute provider available") + + # Auto-provision compute if not done yet + if self._compute_instance_id is None: + try: + import asyncio + loop = asyncio.get_event_loop() + loop.run_until_complete(self.provision_compute()) + except RuntimeError: + # No event loop, create one + asyncio.run(self.provision_compute()) + + pip_cmd = "python -m pip install -q " + " ".join(f'"{pkg}"' for pkg in pip_pkgs) + logger.info("[local_managed] installing pip packages in compute: %s", pip_pkgs) + + try: + # Run installation synchronously in compute + import asyncio + try: + loop = asyncio.get_event_loop() + result = loop.run_until_complete( + self._compute.execute(self._compute_instance_id, pip_cmd, timeout=120) + ) + except RuntimeError: + # No event loop, create one + result = asyncio.run( + self._compute.execute(self._compute_instance_id, pip_cmd, timeout=120) + ) + + if result.get("exit_code", 0) == 0: + logger.info("[local_managed] compute pip install completed") + else: + logger.warning("[local_managed] compute pip install failed: %s", result.get("stderr", "")) + + except Exception as e: + logger.warning("[local_managed] compute pip install error: %s", e) def _ensure_agent(self) -> Any: """Create or return the inner PraisonAI Agent.""" From 36ea19aa75e62923e572481d2645204e0a8e1b87 Mon Sep 17 00:00:00 2001 From: Mervin Praison Date: Fri, 17 Apr 2026 06:57:59 +0100 Subject: [PATCH 2/2] test: force asyncio.run fallback path in compute-bridge tests --- .../tests/managed/test_managed_factory.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/praisonai-agents/tests/managed/test_managed_factory.py b/src/praisonai-agents/tests/managed/test_managed_factory.py index 7a8aaf9e8..9079e3eee 100644 --- a/src/praisonai-agents/tests/managed/test_managed_factory.py +++ b/src/praisonai-agents/tests/managed/test_managed_factory.py @@ -472,7 +472,8 @@ def test_compute_bridge_tool_execute_command(self): original_func = lambda command: "original result" bridge_tool = agent._create_compute_bridge_tool("execute_command", original_func) - with patch.object(agent._compute, 'execute') as mock_execute, \ + with patch.object(agent._compute, 'execute'), \ + patch('asyncio.get_event_loop', side_effect=RuntimeError('no loop')), \ patch('asyncio.run') as mock_asyncio_run: mock_asyncio_run.return_value = {"exit_code": 0, "stdout": "compute result"} @@ -527,7 +528,8 @@ def test_bridge_file_tool_read(self): agent = LocalManagedAgent(compute="local") agent._compute_instance_id = "test_instance" - with patch('asyncio.run') as mock_asyncio_run: + with patch('asyncio.get_event_loop', side_effect=RuntimeError('no loop')), \ + patch('asyncio.run') as mock_asyncio_run: mock_asyncio_run.return_value = {"exit_code": 0, "stdout": "file contents"} result = agent._bridge_file_tool("read_file", "/test/file") @@ -541,7 +543,8 @@ def test_bridge_file_tool_write(self): agent = LocalManagedAgent(compute="local") agent._compute_instance_id = "test_instance" - with patch('asyncio.run') as mock_asyncio_run: + with patch('asyncio.get_event_loop', side_effect=RuntimeError('no loop')), \ + patch('asyncio.run') as mock_asyncio_run: mock_asyncio_run.return_value = {"exit_code": 0, "stdout": ""} result = agent._bridge_file_tool("write_file", "/test/file", "content") @@ -555,7 +558,8 @@ def test_bridge_file_tool_list(self): agent = LocalManagedAgent(compute="local") agent._compute_instance_id = "test_instance" - with patch('asyncio.run') as mock_asyncio_run: + with patch('asyncio.get_event_loop', side_effect=RuntimeError('no loop')), \ + patch('asyncio.run') as mock_asyncio_run: mock_asyncio_run.return_value = {"exit_code": 0, "stdout": "file1\nfile2\n"} result = agent._bridge_file_tool("list_files", "/test/dir")