Skip to content

Commit 4a30739

Browse files
committed
Sandbox Code Mode execution in a subprocess with resource limits
- Execute code in a separate process via multiprocessing to isolate the parent from crashes, infinite loops, and memory exhaustion - Apply RLIMIT_CPU and RLIMIT_AS (Unix) for CPU and memory bounds - Validate input/output variables contain only plain data types (bool, int, float, str, list, tuple, dict) to prevent code injection - Add CodeModeSandbox dataclass for configurable timeout, CPU, and memory limits
1 parent dc60b0f commit 4a30739

3 files changed

Lines changed: 138 additions & 11 deletions

File tree

teaagent/__init__.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,12 @@
1111
register_subagent_tool,
1212
run_chat_agent,
1313
)
14-
from teaagent.code_mode import CodeModeResult, UnsafeCodeError, execute_code_mode
14+
from teaagent.code_mode import (
15+
CodeModeResult,
16+
CodeModeSandbox,
17+
UnsafeCodeError,
18+
execute_code_mode,
19+
)
1520
from teaagent.context import ContextCompactor
1621
from teaagent.eval import EvalCase, EvalReport, run_eval
1722
from teaagent.graph_rag import GraphEdge, KnowledgeGraph, graph_retrieve
@@ -114,6 +119,7 @@
114119
'ChatAgentConfig',
115120
'ClarificationResult',
116121
'CodeModeResult',
122+
'CodeModeSandbox',
117123
'ContextCompactor',
118124
'Decision',
119125
'Document',

teaagent/code_mode.py

Lines changed: 117 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
from __future__ import annotations
22

33
import ast
4+
import multiprocessing
5+
import queue
6+
import traceback
47
from dataclasses import dataclass
58
from typing import Any
69

10+
try:
11+
import resource
12+
except ImportError: # pragma: no cover - resource is Unix-only.
13+
resource = None # type: ignore[assignment]
14+
715
ALLOWED_NODES = {
816
ast.Add,
917
ast.Assign,
@@ -58,24 +66,58 @@ class CodeModeResult:
5866
variables: dict[str, Any]
5967

6068

69+
@dataclass(frozen=True)
70+
class CodeModeSandbox:
71+
timeout_seconds: float = 2.0
72+
cpu_seconds: int = 2
73+
memory_bytes: int = 64 * 1024 * 1024
74+
75+
6176
class UnsafeCodeError(ValueError):
6277
pass
6378

6479

6580
def execute_code_mode(
66-
code: str, *, inputs: dict[str, Any] | None = None
81+
code: str,
82+
*,
83+
inputs: dict[str, Any] | None = None,
84+
sandbox: CodeModeSandbox | None = None,
6785
) -> CodeModeResult:
6886
tree = ast.parse(code, mode='exec')
6987
_validate_tree(tree)
70-
namespace: dict[str, Any] = {'__builtins__': SAFE_BUILTINS}
71-
if inputs:
72-
namespace.update(inputs)
73-
exec(compile(tree, '<teaagent-code-mode>', 'exec'), namespace, namespace)
74-
variables = {
75-
key: value
76-
for key, value in namespace.items()
77-
if key != '__builtins__' and not key.startswith('_')
78-
}
88+
safe_inputs = dict(inputs or {})
89+
_validate_plain_data(safe_inputs, 'inputs')
90+
91+
active_sandbox = sandbox or CodeModeSandbox()
92+
if active_sandbox.timeout_seconds <= 0:
93+
raise UnsafeCodeError('Code Mode timeout must be positive')
94+
if active_sandbox.cpu_seconds <= 0:
95+
raise UnsafeCodeError('Code Mode CPU limit must be positive')
96+
if active_sandbox.memory_bytes <= 0:
97+
raise UnsafeCodeError('Code Mode memory limit must be positive')
98+
99+
context = multiprocessing.get_context('spawn')
100+
result_queue: multiprocessing.Queue[dict[str, Any]] = context.Queue(maxsize=1)
101+
process = context.Process(
102+
target=_execute_code_mode_child,
103+
args=(code, safe_inputs, active_sandbox, result_queue),
104+
)
105+
process.start()
106+
process.join(active_sandbox.timeout_seconds)
107+
if process.is_alive():
108+
process.terminate()
109+
process.join()
110+
raise UnsafeCodeError('Code Mode timed out')
111+
112+
try:
113+
message = result_queue.get_nowait()
114+
except queue.Empty as exc:
115+
raise UnsafeCodeError('Code Mode sandbox exited without a result') from exc
116+
117+
if message['status'] == 'error':
118+
raise UnsafeCodeError(message['error'])
119+
variables = message['variables']
120+
_validate_plain_data(variables, 'variables')
79121
return CodeModeResult(variables=variables)
80122

81123

@@ -87,3 +129,68 @@ def _validate_tree(tree: ast.AST) -> None:
87129
not isinstance(node.func, ast.Name) or node.func.id not in SAFE_BUILTINS
88130
):
89131
raise UnsafeCodeError('Only approved builtin calls are allowed')
132+
133+
134+
def _execute_code_mode_child(
135+
code: str,
136+
inputs: dict[str, Any],
137+
sandbox: CodeModeSandbox,
138+
result_queue: multiprocessing.Queue[dict[str, Any]],
139+
) -> None:
140+
try:
141+
_apply_resource_limits(sandbox)
142+
namespace: dict[str, Any] = {'__builtins__': SAFE_BUILTINS}
143+
namespace.update(inputs)
144+
exec(compile(code, '<teaagent-code-mode>', 'exec'), namespace, namespace)
145+
variables = {
146+
key: value
147+
for key, value in namespace.items()
148+
if key != '__builtins__' and not key.startswith('_')
149+
}
150+
_validate_plain_data(variables, 'variables')
151+
except Exception as exc: # pragma: no cover - exercised through parent process.
152+
result_queue.put(
153+
{
154+
'status': 'error',
155+
'error': f'{type(exc).__name__}: {exc}',
156+
'traceback': traceback.format_exc(),
157+
}
158+
)
159+
else:
160+
result_queue.put({'status': 'ok', 'variables': variables})
161+
162+
163+
def _apply_resource_limits(sandbox: CodeModeSandbox) -> None:
164+
if resource is None:
165+
return
166+
_, cpu_hard = resource.getrlimit(resource.RLIMIT_CPU)
167+
resource.setrlimit(resource.RLIMIT_CPU, (sandbox.cpu_seconds, cpu_hard))
168+
if hasattr(resource, 'RLIMIT_AS'):
169+
_, hard = resource.getrlimit(resource.RLIMIT_AS)
170+
soft = sandbox.memory_bytes
171+
if hard != resource.RLIM_INFINITY:
172+
soft = min(soft, hard)
173+
try:
174+
resource.setrlimit(resource.RLIMIT_AS, (soft, hard))
175+
except ValueError:
176+
# macOS reports RLIMIT_AS but rejects lowering it; wall/CPU limits still
177+
# keep Code Mode isolated from the parent process.
178+
pass
179+
180+
181+
def _validate_plain_data(value: Any, label: str) -> None:
182+
if value is None or isinstance(value, (bool, int, float, str)):
183+
return
184+
if isinstance(value, list) or isinstance(value, tuple):
185+
for index, item in enumerate(value):
186+
_validate_plain_data(item, f'{label}[{index}]')
187+
return
188+
if isinstance(value, dict):
189+
for key, item in value.items():
190+
if not isinstance(key, str):
191+
raise UnsafeCodeError(f'{label} contains a non-string key')
192+
_validate_plain_data(item, f'{label}.{key}')
193+
return
194+
raise UnsafeCodeError(
195+
f'{label} contains unsupported value type: {type(value).__name__}'
196+
)

tests/test_p2_primitives.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
StatelessMCPRequest,
1111
ToolAnnotations,
1212
ToolRegistry,
13+
CodeModeSandbox,
1314
UnsafeCodeError,
1415
assess_managed_agent_readiness,
1516
assess_provider_portability,
@@ -84,6 +85,19 @@ def test_code_mode_blocks_imports_and_unknown_calls(self) -> None:
8485
with self.assertRaises(UnsafeCodeError):
8586
execute_code_mode("value = open('x')")
8687

88+
def test_code_mode_rejects_non_plain_inputs_and_outputs(self) -> None:
89+
with self.assertRaises(UnsafeCodeError):
90+
execute_code_mode('value = len(items)', inputs={'items': object()})
91+
with self.assertRaises(UnsafeCodeError):
92+
execute_code_mode('value = range(3)')
93+
94+
def test_code_mode_times_out_in_sandbox(self) -> None:
95+
with self.assertRaises(UnsafeCodeError):
96+
execute_code_mode(
97+
'for index in range(100000000):\n value = index',
98+
sandbox=CodeModeSandbox(timeout_seconds=0.01),
99+
)
100+
87101
def test_stateless_mcp_request_executes_via_registry(self) -> None:
88102
request = StatelessMCPRequest.create(
89103
tool_name='sum_values',

0 commit comments

Comments
 (0)