11from __future__ import annotations
22
33import ast
4+ import multiprocessing
5+ import queue
6+ import traceback
47from dataclasses import dataclass
58from 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+
715ALLOWED_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+
6176class UnsafeCodeError (ValueError ):
6277 pass
6378
6479
6580def 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+ )
0 commit comments