Skip to content

Commit 2ca4005

Browse files
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 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Mervin Praison <MervinPraison@users.noreply.github.com>
1 parent af1fab4 commit 2ca4005

File tree

3 files changed

+522
-12
lines changed

3 files changed

+522
-12
lines changed

src/praisonai-agents/tests/managed/test_managed_factory.py

Lines changed: 291 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def test_defaults(self):
5353
cfg = LocalManagedConfig()
5454
assert cfg.name == "Agent"
5555
assert cfg.model == "gpt-4o"
56-
assert cfg.sandbox_type == "subprocess"
56+
assert cfg.host_packages_ok is False
5757
assert cfg.max_turns == 25
5858
assert "execute_command" in cfg.tools
5959

@@ -471,3 +471,293 @@ def test_provision_execute_shutdown_local(self):
471471

472472
asyncio.run(agent.shutdown_compute())
473473
assert agent._compute_instance_id is None
474+
475+
476+
# ====================================================================== #
477+
# Security tests - package installation and sandbox behavior
478+
# ====================================================================== #
479+
480+
class TestManagedSandboxSafety:
481+
def test_install_packages_without_compute_raises(self):
482+
"""Test that package installation without compute provider raises ManagedSandboxRequired."""
483+
import pytest
484+
from praisonai.integrations.managed_local import LocalManagedAgent, LocalManagedConfig
485+
from praisonai.integrations.managed_agents import ManagedSandboxRequired
486+
487+
cfg = LocalManagedConfig(packages={"pip": ["requests"]})
488+
agent = LocalManagedAgent(config=cfg)
489+
490+
with pytest.raises(ManagedSandboxRequired, match="Package installation requires compute provider"):
491+
agent._install_packages()
492+
493+
def test_install_packages_with_host_packages_ok_works(self):
494+
"""Test that package installation with explicit opt-out works."""
495+
from unittest.mock import patch, MagicMock
496+
from praisonai.integrations.managed_local import LocalManagedAgent, LocalManagedConfig
497+
498+
cfg = LocalManagedConfig(packages={"pip": ["requests"]}, host_packages_ok=True)
499+
agent = LocalManagedAgent(config=cfg)
500+
501+
with patch('subprocess.run') as mock_run:
502+
mock_run.return_value = MagicMock()
503+
agent._install_packages()
504+
mock_run.assert_called_once()
505+
args = mock_run.call_args[0][0]
506+
assert "pip" in args
507+
assert "install" in args
508+
assert "requests" in args
509+
510+
def test_install_packages_with_compute_runs_in_sandbox(self):
511+
"""Test that packages install in compute sandbox when compute provider attached."""
512+
import asyncio
513+
from unittest.mock import patch, MagicMock, AsyncMock
514+
from praisonai.integrations.managed_local import LocalManagedAgent, LocalManagedConfig
515+
516+
mock_compute = MagicMock()
517+
mock_compute.execute = AsyncMock(return_value={"exit_code": 0, "stdout": "success", "stderr": ""})
518+
519+
cfg = LocalManagedConfig(packages={"pip": ["requests"]})
520+
agent = LocalManagedAgent(config=cfg, compute=mock_compute)
521+
agent._compute_instance_id = "test_instance"
522+
523+
with patch('subprocess.run') as mock_subprocess:
524+
agent._install_packages()
525+
# subprocess.run should NOT be called when compute is attached
526+
mock_subprocess.assert_not_called()
527+
# compute.execute should be called instead
528+
mock_compute.execute.assert_called_once()
529+
530+
def test_no_packages_skips_installation(self):
531+
"""Test that no packages specified skips installation entirely."""
532+
from unittest.mock import patch
533+
from praisonai.integrations.managed_local import LocalManagedAgent, LocalManagedConfig
534+
535+
cfg = LocalManagedConfig()
536+
agent = LocalManagedAgent(config=cfg)
537+
538+
with patch('subprocess.run') as mock_run:
539+
agent._install_packages()
540+
mock_run.assert_not_called()
541+
542+
def test_empty_pip_packages_skips_installation(self):
543+
"""Test that empty pip packages list skips installation."""
544+
from unittest.mock import patch
545+
from praisonai.integrations.managed_local import LocalManagedAgent, LocalManagedConfig
546+
547+
cfg = LocalManagedConfig(packages={"pip": []})
548+
agent = LocalManagedAgent(config=cfg)
549+
550+
with patch('subprocess.run') as mock_run:
551+
agent._install_packages()
552+
mock_run.assert_not_called()
553+
554+
def test_exception_message_includes_remediation(self):
555+
"""Test that ManagedSandboxRequired exception includes actionable remediation."""
556+
import pytest
557+
from praisonai.integrations.managed_local import LocalManagedAgent, LocalManagedConfig
558+
from praisonai.integrations.managed_agents import ManagedSandboxRequired
559+
560+
cfg = LocalManagedConfig(packages={"pip": ["dangerous-package"]})
561+
agent = LocalManagedAgent(config=cfg)
562+
563+
with pytest.raises(ManagedSandboxRequired) as exc_info:
564+
agent._install_packages()
565+
566+
error_msg = str(exc_info.value)
567+
assert "dangerous-package" in error_msg
568+
assert "compute='docker'" in error_msg
569+
assert "host_packages_ok=True" in error_msg
570+
571+
def test_managed_sandbox_required_exception_creation(self):
572+
"""Test ManagedSandboxRequired exception can be created and has correct default message."""
573+
from praisonai.integrations.managed_agents import ManagedSandboxRequired
574+
575+
exc = ManagedSandboxRequired()
576+
assert "Package installation requires compute provider for security" in str(exc)
577+
578+
custom_exc = ManagedSandboxRequired("Custom message")
579+
assert str(custom_exc) == "Custom message"
580+
581+
582+
class TestComputeToolBridge:
583+
"""Test compute-bridged tool execution routing."""
584+
585+
def test_tools_use_compute_bridge_when_compute_attached(self):
586+
"""Test that shell tools use compute bridge when compute provider attached."""
587+
from unittest.mock import MagicMock
588+
from praisonai.integrations.managed_local import LocalManagedAgent, LocalManagedConfig
589+
590+
mock_compute = MagicMock()
591+
cfg = LocalManagedConfig(tools=["execute_command", "read_file", "write_file", "list_files"])
592+
agent = LocalManagedAgent(config=cfg, compute=mock_compute)
593+
594+
tools = agent._resolve_tools()
595+
596+
# Should have 4 compute-bridged tools
597+
shell_tools = [t for t in tools if hasattr(t, '__name__') and
598+
t.__name__ in {"execute_command", "read_file", "write_file", "list_files"}]
599+
assert len(shell_tools) == 4
600+
601+
def test_tools_use_host_when_no_compute(self):
602+
"""Test that tools use host versions when no compute provider."""
603+
from unittest.mock import patch, MagicMock
604+
from praisonai.integrations.managed_local import LocalManagedAgent, LocalManagedConfig
605+
606+
cfg = LocalManagedConfig(tools=["execute_command"])
607+
agent = LocalManagedAgent(config=cfg)
608+
609+
with patch('praisonaiagents.tools.execute_command') as mock_tool:
610+
mock_tool.__name__ = "execute_command"
611+
tools = agent._resolve_tools()
612+
# Should use host tool, not compute bridge
613+
assert mock_tool in tools
614+
615+
def test_compute_execute_command_bridge(self):
616+
"""Test compute-bridged execute_command works correctly."""
617+
import asyncio
618+
from unittest.mock import MagicMock, AsyncMock
619+
from praisonai.integrations.managed_local import LocalManagedAgent, LocalManagedConfig
620+
621+
mock_compute = MagicMock()
622+
mock_compute.execute = AsyncMock(return_value={
623+
"stdout": "hello world",
624+
"stderr": "",
625+
"exit_code": 0
626+
})
627+
628+
agent = LocalManagedAgent(compute=mock_compute)
629+
agent._compute_instance_id = "test_instance"
630+
631+
execute_command = agent._create_compute_execute_command()
632+
result = execute_command("echo hello world")
633+
634+
assert result == "hello world"
635+
mock_compute.execute.assert_called_once_with("test_instance", "echo hello world", timeout=300)
636+
637+
def test_compute_execute_command_with_stderr(self):
638+
"""Test compute-bridged execute_command handles stderr correctly."""
639+
import asyncio
640+
from unittest.mock import MagicMock, AsyncMock
641+
from praisonai.integrations.managed_local import LocalManagedAgent
642+
643+
mock_compute = MagicMock()
644+
mock_compute.execute = AsyncMock(return_value={
645+
"stdout": "output",
646+
"stderr": "warning",
647+
"exit_code": 1
648+
})
649+
650+
agent = LocalManagedAgent(compute=mock_compute)
651+
agent._compute_instance_id = "test_instance"
652+
653+
execute_command = agent._create_compute_execute_command()
654+
result = execute_command("failing_command")
655+
656+
assert "output" in result
657+
assert "STDERR: warning" in result
658+
assert "Exit code: 1" in result
659+
660+
def test_compute_read_file_bridge(self):
661+
"""Test compute-bridged read_file works correctly."""
662+
from unittest.mock import MagicMock, AsyncMock
663+
from praisonai.integrations.managed_local import LocalManagedAgent
664+
665+
mock_compute = MagicMock()
666+
mock_compute.execute = AsyncMock(return_value={
667+
"stdout": "file contents",
668+
"stderr": "",
669+
"exit_code": 0
670+
})
671+
672+
agent = LocalManagedAgent(compute=mock_compute)
673+
agent._compute_instance_id = "test_instance"
674+
675+
read_file = agent._create_compute_read_file()
676+
result = read_file("/path/to/file.txt")
677+
678+
assert result == "file contents"
679+
mock_compute.execute.assert_called_once_with("test_instance", "cat /path/to/file.txt", timeout=60)
680+
681+
def test_compute_write_file_bridge(self):
682+
"""Test compute-bridged write_file works correctly."""
683+
from unittest.mock import MagicMock, AsyncMock
684+
from praisonai.integrations.managed_local import LocalManagedAgent
685+
686+
mock_compute = MagicMock()
687+
mock_compute.execute = AsyncMock(return_value={
688+
"stdout": "",
689+
"stderr": "",
690+
"exit_code": 0
691+
})
692+
693+
agent = LocalManagedAgent(compute=mock_compute)
694+
agent._compute_instance_id = "test_instance"
695+
696+
write_file = agent._create_compute_write_file()
697+
result = write_file("/path/to/file.txt", "file content")
698+
699+
assert "File written successfully" in result
700+
# Check that the command was properly escaped
701+
mock_compute.execute.assert_called_once()
702+
call_args = mock_compute.execute.call_args
703+
assert "echo" in call_args[0][1]
704+
assert "/path/to/file.txt" in call_args[0][1]
705+
706+
def test_compute_list_files_bridge(self):
707+
"""Test compute-bridged list_files works correctly."""
708+
from unittest.mock import MagicMock, AsyncMock
709+
from praisonai.integrations.managed_local import LocalManagedAgent
710+
711+
mock_compute = MagicMock()
712+
mock_compute.execute = AsyncMock(return_value={
713+
"stdout": "file1.txt\nfile2.txt\n",
714+
"stderr": "",
715+
"exit_code": 0
716+
})
717+
718+
agent = LocalManagedAgent(compute=mock_compute)
719+
agent._compute_instance_id = "test_instance"
720+
721+
list_files = agent._create_compute_list_files()
722+
result = list_files("/some/dir")
723+
724+
assert "file1.txt" in result
725+
assert "file2.txt" in result
726+
mock_compute.execute.assert_called_once_with("test_instance", "ls -la /some/dir", timeout=60)
727+
728+
def test_compute_tools_require_provisioned_instance(self):
729+
"""Test that compute tools raise error when no instance is provisioned."""
730+
import pytest
731+
from praisonai.integrations.managed_local import LocalManagedAgent
732+
733+
mock_compute = MagicMock()
734+
agent = LocalManagedAgent(compute=mock_compute)
735+
# Don't set _compute_instance_id
736+
737+
execute_command = agent._create_compute_execute_command()
738+
with pytest.raises(RuntimeError, match="No compute provider provisioned"):
739+
execute_command("echo test")
740+
741+
def test_auto_provision_compute_in_ensure_agent(self):
742+
"""Test that _ensure_agent auto-provisions compute when needed."""
743+
import asyncio
744+
from unittest.mock import patch, MagicMock, AsyncMock
745+
from praisonai.integrations.managed_local import LocalManagedAgent, LocalManagedConfig
746+
747+
mock_compute = MagicMock()
748+
mock_info = MagicMock()
749+
mock_info.instance_id = "auto_provisioned_instance"
750+
751+
cfg = LocalManagedConfig()
752+
agent = LocalManagedAgent(config=cfg, compute=mock_compute)
753+
754+
with patch.object(agent, 'provision_compute', new_callable=AsyncMock) as mock_provision:
755+
mock_provision.return_value = mock_info
756+
with patch('praisonaiagents.Agent') as mock_agent_class:
757+
mock_agent_class.return_value = MagicMock()
758+
759+
inner_agent = agent._ensure_agent()
760+
761+
# Should have auto-provisioned compute
762+
mock_provision.assert_called_once()
763+
assert agent._compute_instance_id == "auto_provisioned_instance"

src/praisonai/praisonai/integrations/managed_agents.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,22 @@
3636
logger = logging.getLogger(__name__)
3737

3838

39+
class ManagedSandboxRequired(Exception):
40+
"""Raised when package installation or tool execution requires a compute provider for security.
41+
42+
This exception is raised when:
43+
- Packages are specified without a compute provider attached
44+
- host_packages_ok=False (the default for security)
45+
46+
To resolve:
47+
1. Attach a compute provider: LocalManagedAgent(compute="docker")
48+
2. Or explicitly opt-out: LocalManagedConfig(host_packages_ok=True)
49+
"""
50+
51+
def __init__(self, message: str = "Package installation requires compute provider for security"):
52+
super().__init__(message)
53+
54+
3955
# ---------------------------------------------------------------------------
4056
# ManagedConfig — Anthropic-specific configuration dataclass
4157
# Lives in the Wrapper (not Core SDK) because its fields map directly to

0 commit comments

Comments
 (0)