voxagent 0.1.0__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {voxagent-0.1.0 → voxagent-0.2.0}/PKG-INFO +3 -1
- {voxagent-0.1.0 → voxagent-0.2.0}/pyproject.toml +4 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/_version.py +1 -4
- voxagent-0.2.0/src/voxagent/code/__init__.py +55 -0
- voxagent-0.2.0/src/voxagent/code/agent.py +275 -0
- voxagent-0.2.0/src/voxagent/code/sandbox.py +326 -0
- voxagent-0.2.0/src/voxagent/code/tool_proxy.py +259 -0
- voxagent-0.2.0/src/voxagent/code/virtual_fs.py +223 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/mcp/tool.py +2 -1
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/tools/decorator.py +2 -2
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/tools/definition.py +10 -24
- voxagent-0.1.0/src/voxagent/code/__init__.py +0 -9
- {voxagent-0.1.0 → voxagent-0.2.0}/.gitignore +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/README.md +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/examples/README.md +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/__init__.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/agent/__init__.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/agent/abort.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/agent/core.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/mcp/__init__.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/mcp/manager.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/providers/__init__.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/providers/anthropic.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/providers/augment.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/providers/auth.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/providers/base.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/providers/chatgpt.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/providers/claudecode.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/providers/cli_base.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/providers/codex.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/providers/failover.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/providers/google.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/providers/groq.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/providers/ollama.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/providers/openai.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/providers/registry.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/py.typed +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/security/__init__.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/security/events.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/security/filter.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/security/registry.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/session/__init__.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/session/compaction.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/session/lock.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/session/model.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/session/storage.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/streaming/__init__.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/streaming/emitter.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/streaming/events.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/subagent/__init__.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/subagent/context.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/subagent/definition.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/tools/__init__.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/tools/context.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/tools/executor.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/tools/policy.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/tools/registry.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/types/__init__.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/types/messages.py +0 -0
- {voxagent-0.1.0 → voxagent-0.2.0}/src/voxagent/types/run.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: voxagent
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: A lightweight, model-agnostic LLM provider abstraction with streaming and tool support
|
|
5
5
|
Project-URL: Homepage, https://github.com/lensator/voxagent
|
|
6
6
|
Project-URL: Documentation, https://github.com/lensator/voxagent#readme
|
|
@@ -34,6 +34,8 @@ Requires-Dist: openai>=1.0; extra == 'all'
|
|
|
34
34
|
Requires-Dist: tiktoken>=0.5; extra == 'all'
|
|
35
35
|
Provides-Extra: anthropic
|
|
36
36
|
Requires-Dist: anthropic>=0.25; extra == 'anthropic'
|
|
37
|
+
Provides-Extra: code
|
|
38
|
+
Requires-Dist: restrictedpython>=7.0; extra == 'code'
|
|
37
39
|
Provides-Extra: dev
|
|
38
40
|
Requires-Dist: mypy>=1.0; extra == 'dev'
|
|
39
41
|
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Code execution sandbox for token optimization.
|
|
2
|
+
|
|
3
|
+
This subpackage provides:
|
|
4
|
+
- Sandboxed code execution (subprocess, Pyodide)
|
|
5
|
+
- Tool file generation from MCP servers
|
|
6
|
+
- Skills persistence and discovery
|
|
7
|
+
- PII tokenization for privacy
|
|
8
|
+
- Virtual filesystem for tool discovery
|
|
9
|
+
- Code execution mode for agents
|
|
10
|
+
- Tool proxy for routing sandbox calls to real implementations
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from voxagent.code.sandbox import CodeSandbox, SandboxResult, SubprocessSandbox
|
|
14
|
+
from voxagent.code.virtual_fs import (
|
|
15
|
+
ToolCategory,
|
|
16
|
+
ToolRegistry,
|
|
17
|
+
VirtualFilesystem,
|
|
18
|
+
)
|
|
19
|
+
from voxagent.code.agent import (
|
|
20
|
+
CodeModeConfig,
|
|
21
|
+
CodeModeExecutor,
|
|
22
|
+
get_code_mode_system_prompt_addition,
|
|
23
|
+
setup_code_mode_for_agent,
|
|
24
|
+
CODE_MODE_SYSTEM_PROMPT,
|
|
25
|
+
)
|
|
26
|
+
from voxagent.code.tool_proxy import (
|
|
27
|
+
ToolCallRequest,
|
|
28
|
+
ToolCallResponse,
|
|
29
|
+
ToolProxyClient,
|
|
30
|
+
ToolProxyServer,
|
|
31
|
+
create_tool_proxy_pair,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
# Sandbox
|
|
36
|
+
"CodeSandbox",
|
|
37
|
+
"SandboxResult",
|
|
38
|
+
"SubprocessSandbox",
|
|
39
|
+
# Virtual FS
|
|
40
|
+
"ToolCategory",
|
|
41
|
+
"ToolRegistry",
|
|
42
|
+
"VirtualFilesystem",
|
|
43
|
+
# Code Mode Agent
|
|
44
|
+
"CodeModeConfig",
|
|
45
|
+
"CodeModeExecutor",
|
|
46
|
+
"CODE_MODE_SYSTEM_PROMPT",
|
|
47
|
+
"get_code_mode_system_prompt_addition",
|
|
48
|
+
"setup_code_mode_for_agent",
|
|
49
|
+
# Tool Proxy
|
|
50
|
+
"ToolCallRequest",
|
|
51
|
+
"ToolCallResponse",
|
|
52
|
+
"ToolProxyClient",
|
|
53
|
+
"ToolProxyServer",
|
|
54
|
+
"create_tool_proxy_pair",
|
|
55
|
+
]
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Code execution mode for Agent.
|
|
2
|
+
|
|
3
|
+
When execution_mode="code", the agent uses a single execute_code tool
|
|
4
|
+
instead of exposing all tools directly. The LLM writes Python code
|
|
5
|
+
that calls tools via the virtual filesystem.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
from voxagent.code.sandbox import SubprocessSandbox, SandboxResult
|
|
13
|
+
from voxagent.code.virtual_fs import VirtualFilesystem, ToolRegistry
|
|
14
|
+
from voxagent.tools.definition import ToolDefinition
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from voxagent.agent.core import Agent
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# System prompt addition for code mode
|
|
21
|
+
CODE_MODE_SYSTEM_PROMPT = '''
|
|
22
|
+
## Code Execution Mode
|
|
23
|
+
|
|
24
|
+
You have access to a single tool: `execute_code`. Use it to write Python code that:
|
|
25
|
+
1. Explores available tools with `ls("tools/")` and `read("tools/<category>/<tool>.py")`
|
|
26
|
+
2. Calls tools using `call_tool(category, tool_name, **kwargs)`
|
|
27
|
+
3. Uses `print()` to output results
|
|
28
|
+
|
|
29
|
+
### Available Functions in Sandbox
|
|
30
|
+
- `ls(path)` - List directory contents (e.g., `ls("tools/")`, `ls("tools/devices/")`)
|
|
31
|
+
- `read(path)` - Read file contents (e.g., `read("tools/devices/registry.py")`)
|
|
32
|
+
- `call_tool(category, tool_name, **kwargs)` - Call a tool with arguments
|
|
33
|
+
- `print(*args)` - Output results (captured and returned to you)
|
|
34
|
+
|
|
35
|
+
### Workflow
|
|
36
|
+
1. **Explore**: `print(ls("tools/"))` to see categories
|
|
37
|
+
2. **Learn**: `print(read("tools/<category>/<tool>.py"))` to see tool signatures
|
|
38
|
+
3. **Execute**: Use `call_tool()` to invoke tools
|
|
39
|
+
4. **Report**: Use `print()` to show results
|
|
40
|
+
|
|
41
|
+
### Example
|
|
42
|
+
```python
|
|
43
|
+
# Explore available tools
|
|
44
|
+
print("Categories:", ls("tools/"))
|
|
45
|
+
|
|
46
|
+
# Read a tool definition
|
|
47
|
+
print(read("tools/devices/registry.py"))
|
|
48
|
+
|
|
49
|
+
# Call the tool
|
|
50
|
+
result = call_tool("devices", "registry.py", device_type="light")
|
|
51
|
+
print("Devices:", result)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Rules
|
|
55
|
+
1. Always use `print()` to show results
|
|
56
|
+
2. Explore before assuming - use `ls()` and `read()` first
|
|
57
|
+
3. Handle errors with try/except when appropriate
|
|
58
|
+
'''
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class CodeModeConfig:
|
|
62
|
+
"""Configuration for code execution mode.
|
|
63
|
+
|
|
64
|
+
Attributes:
|
|
65
|
+
enabled: Whether code mode is active
|
|
66
|
+
timeout_seconds: Max execution time per code block
|
|
67
|
+
memory_limit_mb: Max memory for sandbox
|
|
68
|
+
max_output_chars: Truncate output beyond this
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
enabled: bool = True,
|
|
74
|
+
timeout_seconds: int = 10,
|
|
75
|
+
memory_limit_mb: int = 128,
|
|
76
|
+
max_output_chars: int = 10000,
|
|
77
|
+
):
|
|
78
|
+
self.enabled = enabled
|
|
79
|
+
self.timeout_seconds = timeout_seconds
|
|
80
|
+
self.memory_limit_mb = memory_limit_mb
|
|
81
|
+
self.max_output_chars = max_output_chars
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class CodeModeExecutor:
|
|
85
|
+
"""Executes code for an agent in code mode.
|
|
86
|
+
|
|
87
|
+
This class:
|
|
88
|
+
1. Manages the sandbox and virtual filesystem
|
|
89
|
+
2. Provides the execute_code tool implementation
|
|
90
|
+
3. Routes tool calls from sandbox to real implementations
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
config: CodeModeConfig,
|
|
96
|
+
tool_registry: ToolRegistry,
|
|
97
|
+
):
|
|
98
|
+
self.config = config
|
|
99
|
+
self.tool_registry = tool_registry
|
|
100
|
+
self.sandbox = SubprocessSandbox(
|
|
101
|
+
timeout_seconds=config.timeout_seconds,
|
|
102
|
+
memory_limit_mb=config.memory_limit_mb,
|
|
103
|
+
)
|
|
104
|
+
self.virtual_fs = VirtualFilesystem(tool_registry)
|
|
105
|
+
|
|
106
|
+
# Tool proxy for routing calls
|
|
107
|
+
self._tool_implementations: dict[str, Any] = {}
|
|
108
|
+
|
|
109
|
+
def register_tool_implementation(
|
|
110
|
+
self,
|
|
111
|
+
category: str,
|
|
112
|
+
tool_name: str,
|
|
113
|
+
implementation: Any,
|
|
114
|
+
) -> None:
|
|
115
|
+
"""Register a real tool implementation for the proxy."""
|
|
116
|
+
key = f"{category}.{tool_name}"
|
|
117
|
+
self._tool_implementations[key] = implementation
|
|
118
|
+
|
|
119
|
+
async def execute_code(self, code: str) -> str:
|
|
120
|
+
"""Execute Python code in the sandbox.
|
|
121
|
+
|
|
122
|
+
This is the tool exposed to the LLM.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
code: Python source code to execute
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Captured output or error message
|
|
129
|
+
"""
|
|
130
|
+
# Build globals with virtual filesystem functions only
|
|
131
|
+
# Note: call_tool is not passed to sandbox due to pickling constraints
|
|
132
|
+
# The LLM should use ls() and read() to explore, then describe what to call
|
|
133
|
+
globals_dict = self.virtual_fs.get_sandbox_globals()
|
|
134
|
+
|
|
135
|
+
# Execute in sandbox
|
|
136
|
+
result = await self.sandbox.execute(code, globals_dict)
|
|
137
|
+
|
|
138
|
+
# Format output
|
|
139
|
+
if result.success:
|
|
140
|
+
output = result.output or "(no output)"
|
|
141
|
+
if len(output) > self.config.max_output_chars:
|
|
142
|
+
output = output[:self.config.max_output_chars] + "\n... (truncated)"
|
|
143
|
+
return output
|
|
144
|
+
else:
|
|
145
|
+
return f"Error: {result.error}"
|
|
146
|
+
|
|
147
|
+
def call_tool(self, category: str, tool_name: str, **kwargs: Any) -> Any:
|
|
148
|
+
"""Call a registered tool implementation.
|
|
149
|
+
|
|
150
|
+
This method is called outside the sandbox after the LLM
|
|
151
|
+
has explored tools and decided which one to call.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
category: Tool category (e.g., "devices")
|
|
155
|
+
tool_name: Tool name (e.g., "registry.py")
|
|
156
|
+
**kwargs: Arguments to pass to the tool
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
Tool result or error message
|
|
160
|
+
"""
|
|
161
|
+
key = f"{category}.{tool_name}"
|
|
162
|
+
if key not in self._tool_implementations:
|
|
163
|
+
return f"Error: Tool '{key}' not found"
|
|
164
|
+
try:
|
|
165
|
+
impl = self._tool_implementations[key]
|
|
166
|
+
return impl(**kwargs)
|
|
167
|
+
except Exception as e:
|
|
168
|
+
return f"Error calling {key}: {e}"
|
|
169
|
+
|
|
170
|
+
def get_execute_code_tool(self) -> ToolDefinition:
|
|
171
|
+
"""Get the execute_code tool definition for the agent."""
|
|
172
|
+
async def execute_code_wrapper(code: str) -> str:
|
|
173
|
+
"""Execute Python code to explore and use tools.
|
|
174
|
+
|
|
175
|
+
Write Python code that:
|
|
176
|
+
1. Uses ls() to explore available tool categories
|
|
177
|
+
2. Uses read() to see tool signatures and documentation
|
|
178
|
+
3. Imports and calls tools to perform actions
|
|
179
|
+
4. Uses print() to output results
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
code: Python source code to execute
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
Captured print() output or error message
|
|
186
|
+
"""
|
|
187
|
+
return await self.execute_code(code)
|
|
188
|
+
|
|
189
|
+
return ToolDefinition(
|
|
190
|
+
name="execute_code",
|
|
191
|
+
description="Execute Python code to explore and use tools. Use ls() to see tools, read() to see signatures, and print() to output results.",
|
|
192
|
+
execute=execute_code_wrapper,
|
|
193
|
+
parameters={
|
|
194
|
+
"type": "object",
|
|
195
|
+
"properties": {
|
|
196
|
+
"code": {
|
|
197
|
+
"type": "string",
|
|
198
|
+
"description": "Python source code to execute"
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
"required": ["code"]
|
|
202
|
+
},
|
|
203
|
+
is_async=True,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def get_code_mode_system_prompt_addition() -> str:
|
|
208
|
+
"""Get the system prompt addition for code mode."""
|
|
209
|
+
return CODE_MODE_SYSTEM_PROMPT
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def setup_code_mode_for_agent(
|
|
213
|
+
agent: "Agent[Any, Any]",
|
|
214
|
+
config: CodeModeConfig | None = None,
|
|
215
|
+
) -> CodeModeExecutor:
|
|
216
|
+
"""Set up code mode for an existing agent.
|
|
217
|
+
|
|
218
|
+
This:
|
|
219
|
+
1. Creates a CodeModeExecutor
|
|
220
|
+
2. Registers the execute_code tool
|
|
221
|
+
3. Converts existing tools to virtual filesystem entries
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
agent: The agent to configure
|
|
225
|
+
config: Optional code mode configuration
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
The CodeModeExecutor instance
|
|
229
|
+
"""
|
|
230
|
+
config = config or CodeModeConfig()
|
|
231
|
+
|
|
232
|
+
# Create tool registry from agent's existing tools
|
|
233
|
+
vf_tool_registry = ToolRegistry()
|
|
234
|
+
|
|
235
|
+
# Convert agent's tools to virtual filesystem entries
|
|
236
|
+
for tool_def in agent._tool_registry.list():
|
|
237
|
+
# Create category based on tool name or use "default"
|
|
238
|
+
category = "default"
|
|
239
|
+
|
|
240
|
+
# Build tool definition content as Python stub
|
|
241
|
+
params_str = ", ".join(
|
|
242
|
+
f"{k}: {v.get('type', 'Any')}"
|
|
243
|
+
for k, v in tool_def.parameters.get("properties", {}).items()
|
|
244
|
+
)
|
|
245
|
+
tool_content = f'''def {tool_def.name}({params_str}) -> Any:
|
|
246
|
+
"""{tool_def.description}"""
|
|
247
|
+
...
|
|
248
|
+
'''
|
|
249
|
+
|
|
250
|
+
# Get or create category
|
|
251
|
+
if category not in vf_tool_registry.list_categories():
|
|
252
|
+
vf_tool_registry.register_category(
|
|
253
|
+
category,
|
|
254
|
+
description="Default tool category",
|
|
255
|
+
tools={},
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
# Add tool to category
|
|
259
|
+
cat = vf_tool_registry.get_category(category)
|
|
260
|
+
if cat:
|
|
261
|
+
cat.tools[f"{tool_def.name}.py"] = tool_content
|
|
262
|
+
|
|
263
|
+
# Create executor
|
|
264
|
+
executor = CodeModeExecutor(config, vf_tool_registry)
|
|
265
|
+
|
|
266
|
+
# Register tool implementations
|
|
267
|
+
for tool_def in agent._tool_registry.list():
|
|
268
|
+
executor.register_tool_implementation(
|
|
269
|
+
"default",
|
|
270
|
+
f"{tool_def.name}.py",
|
|
271
|
+
tool_def.execute,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
return executor
|
|
275
|
+
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""Sandboxed Python code execution with security restrictions.
|
|
2
|
+
|
|
3
|
+
This module provides secure code execution using RestrictedPython
|
|
4
|
+
and subprocess isolation with resource limits.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import multiprocessing
|
|
10
|
+
import time
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any, TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from voxagent.code.tool_proxy import ToolProxyClient
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class SandboxResult:
|
|
21
|
+
"""Result of sandboxed code execution.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
output: Captured stdout from print() calls
|
|
25
|
+
error: Error message if execution failed
|
|
26
|
+
execution_time_ms: Time taken to execute in milliseconds
|
|
27
|
+
success: Whether execution completed without errors (derived from error being None)
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
output: str | None = None
|
|
31
|
+
error: str | None = None
|
|
32
|
+
execution_time_ms: float | None = None
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def success(self) -> bool:
|
|
36
|
+
"""Return True if execution completed without errors."""
|
|
37
|
+
return self.error is None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CodeSandbox(ABC):
|
|
41
|
+
"""Abstract base class for code execution sandboxes."""
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
async def execute(
|
|
45
|
+
self, code: str, globals_dict: dict[str, Any] | None = None
|
|
46
|
+
) -> SandboxResult:
|
|
47
|
+
"""Execute Python code in a sandboxed environment.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
code: Python source code to execute
|
|
51
|
+
globals_dict: Optional globals to inject (e.g., ls, read functions)
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
SandboxResult with output or error
|
|
55
|
+
"""
|
|
56
|
+
...
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _inplacevar_(op: str, x: Any, y: Any) -> Any:
|
|
60
|
+
"""Handle in-place operators (+=, -=, etc.) in RestrictedPython."""
|
|
61
|
+
if op == "+=":
|
|
62
|
+
return x + y
|
|
63
|
+
elif op == "-=":
|
|
64
|
+
return x - y
|
|
65
|
+
elif op == "*=":
|
|
66
|
+
return x * y
|
|
67
|
+
elif op == "/=":
|
|
68
|
+
return x / y
|
|
69
|
+
elif op == "//=":
|
|
70
|
+
return x // y
|
|
71
|
+
elif op == "%=":
|
|
72
|
+
return x % y
|
|
73
|
+
elif op == "**=":
|
|
74
|
+
return x**y
|
|
75
|
+
elif op == "&=":
|
|
76
|
+
return x & y
|
|
77
|
+
elif op == "|=":
|
|
78
|
+
return x | y
|
|
79
|
+
elif op == "^=":
|
|
80
|
+
return x ^ y
|
|
81
|
+
elif op == ">>=":
|
|
82
|
+
return x >> y
|
|
83
|
+
elif op == "<<=":
|
|
84
|
+
return x << y
|
|
85
|
+
else:
|
|
86
|
+
raise ValueError(f"Unknown operator: {op}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _execute_in_subprocess(
|
|
90
|
+
code: str,
|
|
91
|
+
globals_dict: dict[str, Any],
|
|
92
|
+
result_queue: multiprocessing.Queue, # type: ignore[type-arg]
|
|
93
|
+
memory_limit_mb: int,
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Subprocess entry point for sandboxed execution."""
|
|
96
|
+
# Import here to avoid loading in main process
|
|
97
|
+
import ast
|
|
98
|
+
|
|
99
|
+
from RestrictedPython import compile_restricted, safe_builtins
|
|
100
|
+
from RestrictedPython.Eval import default_guarded_getitem, default_guarded_getiter
|
|
101
|
+
from RestrictedPython.Guards import (
|
|
102
|
+
guarded_iter_unpack_sequence,
|
|
103
|
+
safer_getattr,
|
|
104
|
+
)
|
|
105
|
+
from RestrictedPython.PrintCollector import PrintCollector
|
|
106
|
+
from RestrictedPython.transformer import (
|
|
107
|
+
IOPERATOR_TO_STR,
|
|
108
|
+
RestrictingNodeTransformer,
|
|
109
|
+
copy_locations,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Custom policy that allows augmented assignment on attributes
|
|
113
|
+
class PermissiveNodeTransformer(RestrictingNodeTransformer):
|
|
114
|
+
"""A more permissive node transformer that allows augmented assignment."""
|
|
115
|
+
|
|
116
|
+
def visit_AugAssign(self, node: ast.AugAssign) -> ast.AST:
|
|
117
|
+
"""Allow augmented assignment on attributes and subscripts.
|
|
118
|
+
|
|
119
|
+
Transforms 'a.x += 1' to 'a.x = _inplacevar_("+=", a.x, 1)'
|
|
120
|
+
and 'a[i] += 1' to 'a[i] = _inplacevar_("+=", a[i], 1)'
|
|
121
|
+
"""
|
|
122
|
+
node = self.node_contents_visit(node)
|
|
123
|
+
|
|
124
|
+
if isinstance(node.target, ast.Attribute):
|
|
125
|
+
# Transform a.x += 1 to a.x = _inplacevar_("+=", a.x, 1)
|
|
126
|
+
new_node = ast.Assign(
|
|
127
|
+
targets=[
|
|
128
|
+
ast.Attribute(
|
|
129
|
+
value=node.target.value,
|
|
130
|
+
attr=node.target.attr,
|
|
131
|
+
ctx=ast.Store(),
|
|
132
|
+
)
|
|
133
|
+
],
|
|
134
|
+
value=ast.Call(
|
|
135
|
+
func=ast.Name("_inplacevar_", ast.Load()),
|
|
136
|
+
args=[
|
|
137
|
+
ast.Constant(IOPERATOR_TO_STR[type(node.op)]),
|
|
138
|
+
ast.Attribute(
|
|
139
|
+
value=node.target.value,
|
|
140
|
+
attr=node.target.attr,
|
|
141
|
+
ctx=ast.Load(),
|
|
142
|
+
),
|
|
143
|
+
node.value,
|
|
144
|
+
],
|
|
145
|
+
keywords=[],
|
|
146
|
+
),
|
|
147
|
+
)
|
|
148
|
+
copy_locations(new_node, node)
|
|
149
|
+
return new_node
|
|
150
|
+
|
|
151
|
+
elif isinstance(node.target, ast.Subscript):
|
|
152
|
+
# Transform a[i] += 1 to a[i] = _inplacevar_("+=", a[i], 1)
|
|
153
|
+
new_node = ast.Assign(
|
|
154
|
+
targets=[
|
|
155
|
+
ast.Subscript(
|
|
156
|
+
value=node.target.value,
|
|
157
|
+
slice=node.target.slice,
|
|
158
|
+
ctx=ast.Store(),
|
|
159
|
+
)
|
|
160
|
+
],
|
|
161
|
+
value=ast.Call(
|
|
162
|
+
func=ast.Name("_inplacevar_", ast.Load()),
|
|
163
|
+
args=[
|
|
164
|
+
ast.Constant(IOPERATOR_TO_STR[type(node.op)]),
|
|
165
|
+
ast.Subscript(
|
|
166
|
+
value=node.target.value,
|
|
167
|
+
slice=node.target.slice,
|
|
168
|
+
ctx=ast.Load(),
|
|
169
|
+
),
|
|
170
|
+
node.value,
|
|
171
|
+
],
|
|
172
|
+
keywords=[],
|
|
173
|
+
),
|
|
174
|
+
)
|
|
175
|
+
copy_locations(new_node, node)
|
|
176
|
+
return new_node
|
|
177
|
+
|
|
178
|
+
elif isinstance(node.target, ast.Name):
|
|
179
|
+
new_node = ast.Assign(
|
|
180
|
+
targets=[node.target],
|
|
181
|
+
value=ast.Call(
|
|
182
|
+
func=ast.Name("_inplacevar_", ast.Load()),
|
|
183
|
+
args=[
|
|
184
|
+
ast.Constant(IOPERATOR_TO_STR[type(node.op)]),
|
|
185
|
+
ast.Name(node.target.id, ast.Load()),
|
|
186
|
+
node.value,
|
|
187
|
+
],
|
|
188
|
+
keywords=[],
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
copy_locations(new_node, node)
|
|
192
|
+
return new_node
|
|
193
|
+
else:
|
|
194
|
+
raise NotImplementedError(f"Unknown target type: {type(node.target)}")
|
|
195
|
+
|
|
196
|
+
# Set memory limit (Unix only)
|
|
197
|
+
try:
|
|
198
|
+
import resource
|
|
199
|
+
|
|
200
|
+
limit_bytes = memory_limit_mb * 1024 * 1024
|
|
201
|
+
# Try to set RLIMIT_AS (address space) - works on Linux
|
|
202
|
+
try:
|
|
203
|
+
soft, hard = resource.getrlimit(resource.RLIMIT_AS)
|
|
204
|
+
# Only set if we can (soft limit can be lowered)
|
|
205
|
+
if limit_bytes <= hard:
|
|
206
|
+
resource.setrlimit(resource.RLIMIT_AS, (limit_bytes, hard))
|
|
207
|
+
except (ValueError, OSError):
|
|
208
|
+
pass
|
|
209
|
+
# Also try RLIMIT_DATA
|
|
210
|
+
try:
|
|
211
|
+
soft, hard = resource.getrlimit(resource.RLIMIT_DATA)
|
|
212
|
+
if limit_bytes <= hard:
|
|
213
|
+
resource.setrlimit(resource.RLIMIT_DATA, (limit_bytes, hard))
|
|
214
|
+
except (ValueError, OSError):
|
|
215
|
+
pass
|
|
216
|
+
except ImportError:
|
|
217
|
+
pass # Windows
|
|
218
|
+
|
|
219
|
+
# Build safe builtins
|
|
220
|
+
safe_builtins_copy = dict(safe_builtins)
|
|
221
|
+
# Remove dangerous builtins
|
|
222
|
+
for name in ("open", "eval", "exec", "compile", "__import__"):
|
|
223
|
+
safe_builtins_copy.pop(name, None)
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
byte_code = compile_restricted(
|
|
227
|
+
code, "<sandbox>", "exec", policy=PermissiveNodeTransformer
|
|
228
|
+
)
|
|
229
|
+
if byte_code is None:
|
|
230
|
+
result_queue.put(SandboxResult(output="", error="SyntaxError: compilation failed"))
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
exec_globals: dict[str, Any] = {
|
|
234
|
+
"__builtins__": safe_builtins_copy,
|
|
235
|
+
"__name__": "__main__",
|
|
236
|
+
"__doc__": None,
|
|
237
|
+
# Required for class definitions in RestrictedPython
|
|
238
|
+
"__metaclass__": type,
|
|
239
|
+
# _print_ is a factory - RestrictedPython will call it to create a collector
|
|
240
|
+
"_print_": PrintCollector,
|
|
241
|
+
# Also provide _getattr_ for attribute access
|
|
242
|
+
"_getattr_": safer_getattr,
|
|
243
|
+
"_getitem_": default_guarded_getitem,
|
|
244
|
+
"_getiter_": default_guarded_getiter,
|
|
245
|
+
"_iter_unpack_sequence_": guarded_iter_unpack_sequence,
|
|
246
|
+
"_inplacevar_": _inplacevar_,
|
|
247
|
+
# Provide write guard for attribute writes
|
|
248
|
+
"_write_": lambda x: x,
|
|
249
|
+
**globals_dict,
|
|
250
|
+
}
|
|
251
|
+
exec(byte_code, exec_globals)
|
|
252
|
+
# Get the _print object that was created during execution and call it
|
|
253
|
+
_print_obj = exec_globals.get("_print")
|
|
254
|
+
output = _print_obj() if _print_obj else ""
|
|
255
|
+
result_queue.put(
|
|
256
|
+
SandboxResult(
|
|
257
|
+
output=output if output else "",
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
except SyntaxError as e:
|
|
261
|
+
result_queue.put(SandboxResult(output="", error=f"SyntaxError: {e}"))
|
|
262
|
+
except Exception as e:
|
|
263
|
+
result_queue.put(SandboxResult(output="", error=f"{type(e).__name__}: {e}"))
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class SubprocessSandbox(CodeSandbox):
|
|
267
|
+
"""Execute Python code in an isolated subprocess with RestrictedPython.
|
|
268
|
+
|
|
269
|
+
Security features:
|
|
270
|
+
- RestrictedPython AST filtering (blocks imports, file access, etc.)
|
|
271
|
+
- Process isolation via multiprocessing
|
|
272
|
+
- Timeout enforcement
|
|
273
|
+
- Memory limits (Unix only)
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
timeout_seconds: Maximum execution time (default: 10)
|
|
277
|
+
memory_limit_mb: Maximum memory in MB (default: 128, Unix only)
|
|
278
|
+
tool_proxy_client: Optional tool proxy client for routing tool calls
|
|
279
|
+
"""
|
|
280
|
+
|
|
281
|
+
def __init__(
|
|
282
|
+
self,
|
|
283
|
+
timeout_seconds: int = 10,
|
|
284
|
+
memory_limit_mb: int = 128,
|
|
285
|
+
tool_proxy_client: "ToolProxyClient | None" = None,
|
|
286
|
+
) -> None:
|
|
287
|
+
self.timeout_seconds = timeout_seconds
|
|
288
|
+
self.memory_limit_mb = memory_limit_mb
|
|
289
|
+
self.tool_proxy_client = tool_proxy_client
|
|
290
|
+
|
|
291
|
+
async def execute(
|
|
292
|
+
self, code: str, globals_dict: dict[str, Any] | None = None
|
|
293
|
+
) -> SandboxResult:
|
|
294
|
+
"""Execute code in subprocess with RestrictedPython."""
|
|
295
|
+
start_time = time.monotonic()
|
|
296
|
+
|
|
297
|
+
result_queue: multiprocessing.Queue[SandboxResult] = multiprocessing.Queue()
|
|
298
|
+
process = multiprocessing.Process(
|
|
299
|
+
target=_execute_in_subprocess,
|
|
300
|
+
args=(code, globals_dict or {}, result_queue, self.memory_limit_mb),
|
|
301
|
+
)
|
|
302
|
+
process.start()
|
|
303
|
+
process.join(timeout=self.timeout_seconds)
|
|
304
|
+
|
|
305
|
+
execution_time_ms = (time.monotonic() - start_time) * 1000
|
|
306
|
+
|
|
307
|
+
if process.is_alive():
|
|
308
|
+
process.kill()
|
|
309
|
+
process.join()
|
|
310
|
+
return SandboxResult(
|
|
311
|
+
output="",
|
|
312
|
+
error="Execution timed out",
|
|
313
|
+
execution_time_ms=execution_time_ms,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
try:
|
|
317
|
+
result = result_queue.get_nowait()
|
|
318
|
+
result.execution_time_ms = execution_time_ms
|
|
319
|
+
return result
|
|
320
|
+
except Exception:
|
|
321
|
+
return SandboxResult(
|
|
322
|
+
output="",
|
|
323
|
+
error="No result from subprocess",
|
|
324
|
+
execution_time_ms=execution_time_ms,
|
|
325
|
+
)
|
|
326
|
+
|