tinyagent-py 0.0.11__py3-none-any.whl → 0.0.12__py3-none-any.whl
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.
- tinyagent/code_agent/modal_sandbox.py +3 -1
- tinyagent/code_agent/providers/modal_provider.py +45 -13
- tinyagent/code_agent/safety.py +546 -0
- tinyagent/code_agent/tiny_code_agent.py +91 -0
- tinyagent/code_agent/utils.py +59 -10
- tinyagent/hooks/gradio_callback.py +97 -33
- tinyagent/tiny_agent.py +4 -7
- {tinyagent_py-0.0.11.dist-info → tinyagent_py-0.0.12.dist-info}/METADATA +1 -1
- {tinyagent_py-0.0.11.dist-info → tinyagent_py-0.0.12.dist-info}/RECORD +12 -11
- {tinyagent_py-0.0.11.dist-info → tinyagent_py-0.0.12.dist-info}/WHEEL +0 -0
- {tinyagent_py-0.0.11.dist-info → tinyagent_py-0.0.12.dist-info}/licenses/LICENSE +0 -0
- {tinyagent_py-0.0.11.dist-info → tinyagent_py-0.0.12.dist-info}/top_level.txt +0 -0
@@ -63,6 +63,7 @@ def create_sandbox(
|
|
63
63
|
pip_install: Sequence[str] | None = None,
|
64
64
|
image_name: str = "tinyagent-sandbox-image",
|
65
65
|
app_name: str = "persistent-code-session",
|
66
|
+
force_build: bool = False,
|
66
67
|
**sandbox_kwargs,
|
67
68
|
) -> Tuple[modal.Sandbox, modal.App]:
|
68
69
|
"""Create (or lookup) a `modal.Sandbox` pre-configured for code execution.
|
@@ -99,7 +100,7 @@ def create_sandbox(
|
|
99
100
|
|
100
101
|
# Build image -----------------------------------------------------------
|
101
102
|
agent_image = (
|
102
|
-
modal.Image.debian_slim(python_version=python_version)
|
103
|
+
modal.Image.debian_slim(python_version=python_version,force_build=force_build)
|
103
104
|
.apt_install(*apt_packages)
|
104
105
|
.pip_install(*full_pip_list)
|
105
106
|
)
|
@@ -196,6 +197,7 @@ class SandboxSession:
|
|
196
197
|
modal_secrets: modal.Secret,
|
197
198
|
*,
|
198
199
|
timeout: int = 5 * 60,
|
200
|
+
|
199
201
|
**create_kwargs,
|
200
202
|
) -> None:
|
201
203
|
self.modal_secrets = modal_secrets
|
@@ -26,6 +26,7 @@ class ModalProvider(CodeExecutionProvider):
|
|
26
26
|
default_packages: Optional[List[str]] = None,
|
27
27
|
apt_packages: Optional[List[str]] = None,
|
28
28
|
python_version: Optional[str] = None,
|
29
|
+
authorized_imports: list[str] | None = None,
|
29
30
|
modal_secrets: Dict[str, Union[str, None]] | None = None,
|
30
31
|
lazy_init: bool = True,
|
31
32
|
sandbox_name: str = "tinycodeagent-sandbox",
|
@@ -48,6 +49,7 @@ class ModalProvider(CodeExecutionProvider):
|
|
48
49
|
(git, curl, …) so you only need to specify the extras.
|
49
50
|
python_version: Python version used for the sandbox image. If
|
50
51
|
``None`` the current interpreter version is used.
|
52
|
+
authorized_imports: Optional allow-list of modules the user code is permitted to import. Supports wildcard patterns (e.g. "pandas.*"). If ``None`` the safety layer blocks only the predefined dangerous modules.
|
51
53
|
"""
|
52
54
|
|
53
55
|
# Resolve default values ------------------------------------------------
|
@@ -70,6 +72,7 @@ class ModalProvider(CodeExecutionProvider):
|
|
70
72
|
self.default_packages: List[str] = default_packages
|
71
73
|
self.apt_packages: List[str] = apt_packages
|
72
74
|
self.python_version: str = python_version
|
75
|
+
self.authorized_imports = authorized_imports
|
73
76
|
|
74
77
|
# ----------------------------------------------------------------------
|
75
78
|
final_packages = list(set(self.default_packages + (pip_packages or [])))
|
@@ -89,6 +92,7 @@ class ModalProvider(CodeExecutionProvider):
|
|
89
92
|
self.modal_secrets = modal.Secret.from_dict(self.secrets)
|
90
93
|
self.app = None
|
91
94
|
self._app_run_python = None
|
95
|
+
self.is_trusted_code = kwargs.get("trust_code", False)
|
92
96
|
|
93
97
|
self._setup_modal_app()
|
94
98
|
|
@@ -164,18 +168,30 @@ class ModalProvider(CodeExecutionProvider):
|
|
164
168
|
if self.executed_default_codes:
|
165
169
|
print("✔️ default codes already executed")
|
166
170
|
full_code = "\n".join(self.code_tools_definitions) +"\n\n"+code
|
171
|
+
# Code tools and default code are trusted, user code is not
|
167
172
|
else:
|
168
173
|
full_code = "\n".join(self.code_tools_definitions) +"\n\n"+ "\n".join(self.default_python_codes) + "\n\n" + code
|
169
174
|
self.executed_default_codes = True
|
175
|
+
# First execution includes framework code which is trusted
|
170
176
|
|
171
177
|
# Use Modal's native execution methods
|
172
178
|
if self.local_execution:
|
173
|
-
|
174
|
-
|
179
|
+
return self._app_run_python.local(
|
180
|
+
full_code,
|
181
|
+
globals_dict or {},
|
182
|
+
locals_dict or {},
|
183
|
+
self.authorized_imports,
|
184
|
+
self.is_trusted_code,
|
185
|
+
)
|
175
186
|
else:
|
176
|
-
# Use Modal's .remote() method for remote execution
|
177
187
|
with self.app.run():
|
178
|
-
return self._app_run_python.remote(
|
188
|
+
return self._app_run_python.remote(
|
189
|
+
full_code,
|
190
|
+
globals_dict or {},
|
191
|
+
locals_dict or {},
|
192
|
+
self.authorized_imports,
|
193
|
+
self.is_trusted_code,
|
194
|
+
)
|
179
195
|
|
180
196
|
def _log_response(self, response: Dict[str, Any]):
|
181
197
|
"""Log the response from code execution."""
|
@@ -184,15 +200,31 @@ class ModalProvider(CodeExecutionProvider):
|
|
184
200
|
print("#########################<printed_output>#########################")
|
185
201
|
print(response["printed_output"])
|
186
202
|
print("#########################</printed_output>#########################")
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
203
|
+
if response.get("return_value",None) not in [None,""]:
|
204
|
+
print("#########################<return_value>#########################")
|
205
|
+
print(response["return_value"])
|
206
|
+
print("#########################</return_value>#########################")
|
207
|
+
if response.get("stderr",None) not in [None,""]:
|
208
|
+
print("#########################<stderr>#########################")
|
209
|
+
print(response["stderr"])
|
210
|
+
print("#########################</stderr>#########################")
|
211
|
+
if response.get("error_traceback",None) not in [None,""]:
|
212
|
+
print("#########################<traceback>#########################")
|
213
|
+
# Check if this is a security exception and highlight it in red if so
|
214
|
+
error_text = response["error_traceback"]
|
215
|
+
if "SECURITY" in error_text:
|
216
|
+
try:
|
217
|
+
from ..modal_sandbox import COLOR
|
218
|
+
except ImportError:
|
219
|
+
# Fallback colors if modal_sandbox is not available
|
220
|
+
COLOR = {
|
221
|
+
"RED": "\033[91m",
|
222
|
+
"ENDC": "\033[0m",
|
223
|
+
}
|
224
|
+
print(f"{COLOR['RED']}{error_text}{COLOR['ENDC']}")
|
225
|
+
else:
|
226
|
+
print(error_text)
|
227
|
+
print("#########################</traceback>#########################")
|
196
228
|
|
197
229
|
async def cleanup(self):
|
198
230
|
"""Clean up Modal resources."""
|
@@ -0,0 +1,546 @@
|
|
1
|
+
# TinyAgent code execution safety utilities
|
2
|
+
# -----------------------------------------
|
3
|
+
#
|
4
|
+
# This helper module defines *very* lightweight safeguards that are applied
|
5
|
+
# before running any user-supplied Python code inside the Modal sandbox.
|
6
|
+
# The goal is **not** to build a full blown secure interpreter (this would
|
7
|
+
# require a much more sophisticated setup à la Pyodide or the `python-secure`
|
8
|
+
# project). Instead we implement the following pragmatic defence layers:
|
9
|
+
#
|
10
|
+
# 1. Static AST inspection of the submitted code to detect direct `import` or
|
11
|
+
# `from … import …` statements that reference known dangerous modules
|
12
|
+
# (e.g. `os`, `subprocess`, …). This prevents the *most common* attack
|
13
|
+
# vector where an LLM attempts to read or modify the host file-system or
|
14
|
+
# spawn sub-processes.
|
15
|
+
# 2. Runtime patching of the built-in `__import__` hook so that *dynamic*
|
16
|
+
# imports carried out via `importlib` or `__import__(…)` are blocked at
|
17
|
+
# execution time as well.
|
18
|
+
# 3. Static AST inspection to detect calls to dangerous functions like `exec`,
|
19
|
+
# `eval`, `compile`, etc. that could be used to bypass security measures.
|
20
|
+
# 4. Runtime patching of built-in dangerous functions to prevent their use
|
21
|
+
# at execution time.
|
22
|
+
#
|
23
|
+
# The chosen approach keeps the TinyAgent runtime *fast* and *lean* while
|
24
|
+
# still providing a reasonable first line of defence against obviously
|
25
|
+
# malicious code.
|
26
|
+
|
27
|
+
from __future__ import annotations
|
28
|
+
|
29
|
+
import ast
|
30
|
+
import builtins
|
31
|
+
import warnings
|
32
|
+
from typing import Iterable, List, Set, Sequence, Any, Callable
|
33
|
+
import contextlib
|
34
|
+
|
35
|
+
__all__ = [
|
36
|
+
"DANGEROUS_MODULES",
|
37
|
+
"DANGEROUS_FUNCTIONS",
|
38
|
+
"RUNTIME_BLOCKED_FUNCTIONS",
|
39
|
+
"validate_code_safety",
|
40
|
+
"install_import_hook",
|
41
|
+
"function_safety_context",
|
42
|
+
]
|
43
|
+
|
44
|
+
# ---------------------------------------------------------------------------
|
45
|
+
# Threat model / deny-list
|
46
|
+
# ---------------------------------------------------------------------------
|
47
|
+
|
48
|
+
# Non-exhaustive list of modules that grant (direct or indirect) access to the
|
49
|
+
# underlying operating system, spawn sub-processes, perform unrestricted I/O,
|
50
|
+
# or allow the user to circumvent the static import analysis performed below.
|
51
|
+
DANGEROUS_MODULES: Set[str] = {
|
52
|
+
"builtins", # Gives access to exec/eval etc.
|
53
|
+
"ctypes",
|
54
|
+
"importlib",
|
55
|
+
"io",
|
56
|
+
"multiprocessing",
|
57
|
+
"os",
|
58
|
+
"pathlib",
|
59
|
+
"pty",
|
60
|
+
"shlex",
|
61
|
+
"shutil",
|
62
|
+
"signal",
|
63
|
+
"socket",
|
64
|
+
"subprocess",
|
65
|
+
"sys",
|
66
|
+
"tempfile",
|
67
|
+
"threading",
|
68
|
+
"webbrowser",
|
69
|
+
}
|
70
|
+
|
71
|
+
# List of dangerous built-in functions that could be used to bypass security
|
72
|
+
# measures or execute arbitrary code
|
73
|
+
DANGEROUS_FUNCTIONS: Set[str] = {
|
74
|
+
"exec",
|
75
|
+
"eval",
|
76
|
+
"compile",
|
77
|
+
"__import__",
|
78
|
+
"open",
|
79
|
+
"input",
|
80
|
+
"breakpoint",
|
81
|
+
}
|
82
|
+
|
83
|
+
# Functions that should be blocked at runtime (a subset of DANGEROUS_FUNCTIONS)
|
84
|
+
RUNTIME_BLOCKED_FUNCTIONS: Set[str] = {
|
85
|
+
"exec",
|
86
|
+
"eval",
|
87
|
+
}
|
88
|
+
|
89
|
+
# Essential modules that are always allowed, even in untrusted code
|
90
|
+
# These are needed for the framework to function properly
|
91
|
+
ESSENTIAL_MODULES: Set[str] = {
|
92
|
+
"cloudpickle",
|
93
|
+
"tinyagent",
|
94
|
+
"json",
|
95
|
+
"time",
|
96
|
+
"datetime",
|
97
|
+
"requests",
|
98
|
+
|
99
|
+
}
|
100
|
+
|
101
|
+
|
102
|
+
# ---------------------------------------------------------------------------
|
103
|
+
# Utility helpers
|
104
|
+
# ---------------------------------------------------------------------------
|
105
|
+
|
106
|
+
def _is_allowed(module_root: str, allowed: Sequence[str] | None) -> bool:
|
107
|
+
"""Return ``True`` if *module_root* is within *allowed* specification."""
|
108
|
+
|
109
|
+
if allowed is None:
|
110
|
+
# No explicit allow-list means everything that is **not** in the
|
111
|
+
# dangerous list is considered fine.
|
112
|
+
return True
|
113
|
+
|
114
|
+
# Fast path – wildcard allows everything.
|
115
|
+
if "*" in allowed:
|
116
|
+
return True
|
117
|
+
|
118
|
+
for pattern in allowed:
|
119
|
+
if pattern.endswith(".*"):
|
120
|
+
if module_root == pattern[:-2]:
|
121
|
+
return True
|
122
|
+
elif module_root == pattern:
|
123
|
+
return True
|
124
|
+
return False
|
125
|
+
|
126
|
+
|
127
|
+
# ---------------------------------------------------------------------------
|
128
|
+
# Static analysis helpers
|
129
|
+
# ---------------------------------------------------------------------------
|
130
|
+
|
131
|
+
def _iter_import_nodes(tree: ast.AST) -> Iterable[ast.AST]:
|
132
|
+
"""Yield all *import* related nodes from *tree*."""
|
133
|
+
for node in ast.walk(tree):
|
134
|
+
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
135
|
+
yield node
|
136
|
+
|
137
|
+
|
138
|
+
def _extract_module_roots(node: ast.AST) -> List[str]:
|
139
|
+
"""Return the *top-level* module names referenced in an import node."""
|
140
|
+
roots: list[str] = []
|
141
|
+
if isinstance(node, ast.Import):
|
142
|
+
for alias in node.names:
|
143
|
+
roots.append(alias.name.split(".")[0])
|
144
|
+
elif isinstance(node, ast.ImportFrom):
|
145
|
+
if node.module is not None:
|
146
|
+
roots.append(node.module.split(".")[0])
|
147
|
+
return roots
|
148
|
+
|
149
|
+
|
150
|
+
def _check_for_dangerous_function_calls(tree: ast.AST, authorized_functions: Sequence[str] | None = None) -> Set[str]:
|
151
|
+
"""
|
152
|
+
Check for calls to dangerous functions in the AST.
|
153
|
+
|
154
|
+
Parameters
|
155
|
+
----------
|
156
|
+
tree
|
157
|
+
The AST to check
|
158
|
+
authorized_functions
|
159
|
+
Optional white-list of dangerous functions that are allowed
|
160
|
+
|
161
|
+
Returns
|
162
|
+
-------
|
163
|
+
Set[str]
|
164
|
+
Set of dangerous function names found in the code
|
165
|
+
"""
|
166
|
+
dangerous_calls = set()
|
167
|
+
|
168
|
+
# Convert authorized_functions to a set if it's not None and not a boolean
|
169
|
+
authorized_set = set(authorized_functions) if authorized_functions is not None and not isinstance(authorized_functions, bool) else set()
|
170
|
+
|
171
|
+
for node in ast.walk(tree):
|
172
|
+
# Check for direct function calls: func()
|
173
|
+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
|
174
|
+
func_name = node.func.id
|
175
|
+
# Only flag if it's a known dangerous function
|
176
|
+
if func_name in DANGEROUS_FUNCTIONS and func_name not in authorized_set:
|
177
|
+
dangerous_calls.add(func_name)
|
178
|
+
|
179
|
+
# Check for calls via string literals in exec/eval: exec("import os")
|
180
|
+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id in ["exec", "eval"]:
|
181
|
+
# Only check if the function itself is not authorized
|
182
|
+
if node.func.id not in authorized_set:
|
183
|
+
if node.args and isinstance(node.args[0], ast.Constant) and isinstance(node.args[0].value, str):
|
184
|
+
dangerous_calls.add(f"{node.func.id} with string literal")
|
185
|
+
|
186
|
+
# Check for attribute access: builtins.exec()
|
187
|
+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
|
188
|
+
if node.func.attr in DANGEROUS_FUNCTIONS:
|
189
|
+
if isinstance(node.func.value, ast.Name):
|
190
|
+
module_name = node.func.value.id
|
191
|
+
# Focus on builtins module access
|
192
|
+
if module_name == "builtins" and node.func.attr not in authorized_set:
|
193
|
+
func_name = f"{module_name}.{node.func.attr}"
|
194
|
+
dangerous_calls.add(func_name)
|
195
|
+
|
196
|
+
# Check for string manipulation that could be used to bypass security
|
197
|
+
# For example: e = "e" + "x" + "e" + "c"; e("import os")
|
198
|
+
if isinstance(node, ast.Assign):
|
199
|
+
for target in node.targets:
|
200
|
+
if isinstance(target, ast.Name) and isinstance(node.value, ast.BinOp):
|
201
|
+
# Check if we're building a string that could be a dangerous function name
|
202
|
+
potential_name = _extract_string_from_binop(node.value)
|
203
|
+
if potential_name in DANGEROUS_FUNCTIONS and potential_name not in authorized_set:
|
204
|
+
dangerous_calls.add(f"string manipulation to create '{potential_name}'")
|
205
|
+
|
206
|
+
# Check for getattr(builtins, "exec") pattern
|
207
|
+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "getattr":
|
208
|
+
if len(node.args) >= 2 and isinstance(node.args[1], ast.Constant) and isinstance(node.args[1].value, str):
|
209
|
+
attr_name = node.args[1].value
|
210
|
+
if attr_name in DANGEROUS_FUNCTIONS and attr_name not in authorized_set:
|
211
|
+
if isinstance(node.args[0], ast.Name) and node.args[0].id == "builtins":
|
212
|
+
module_name = node.args[0].id
|
213
|
+
dangerous_calls.add(f"getattr({module_name}, '{attr_name}')")
|
214
|
+
|
215
|
+
return dangerous_calls
|
216
|
+
|
217
|
+
|
218
|
+
def _extract_string_from_binop(node: ast.BinOp) -> str:
|
219
|
+
"""
|
220
|
+
Attempt to extract a string from a binary operation node.
|
221
|
+
This helps detect string concatenation that builds dangerous function names.
|
222
|
+
|
223
|
+
For example: "e" + "x" + "e" + "c" -> "exec"
|
224
|
+
|
225
|
+
Parameters
|
226
|
+
----------
|
227
|
+
node
|
228
|
+
The binary operation node
|
229
|
+
|
230
|
+
Returns
|
231
|
+
-------
|
232
|
+
str
|
233
|
+
The extracted string, or empty string if not extractable
|
234
|
+
"""
|
235
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
236
|
+
return node.value
|
237
|
+
|
238
|
+
if not isinstance(node, ast.BinOp):
|
239
|
+
return ""
|
240
|
+
|
241
|
+
# Handle string concatenation
|
242
|
+
if isinstance(node.op, ast.Add):
|
243
|
+
left_str = _extract_string_from_binop(node.left)
|
244
|
+
right_str = _extract_string_from_binop(node.right)
|
245
|
+
return left_str + right_str
|
246
|
+
|
247
|
+
return ""
|
248
|
+
|
249
|
+
|
250
|
+
def _detect_string_obfuscation(tree: ast.AST) -> bool:
|
251
|
+
"""
|
252
|
+
Detect common string obfuscation techniques that might be used to bypass security.
|
253
|
+
|
254
|
+
Parameters
|
255
|
+
----------
|
256
|
+
tree
|
257
|
+
The AST to check
|
258
|
+
|
259
|
+
Returns
|
260
|
+
-------
|
261
|
+
bool
|
262
|
+
True if suspicious string manipulation is detected
|
263
|
+
"""
|
264
|
+
suspicious_patterns = False
|
265
|
+
|
266
|
+
for node in ast.walk(tree):
|
267
|
+
# Check for chr() usage to build strings
|
268
|
+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "chr":
|
269
|
+
suspicious_patterns = True
|
270
|
+
break
|
271
|
+
|
272
|
+
# Check for ord() usage in combination with string operations
|
273
|
+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "ord":
|
274
|
+
suspicious_patterns = True
|
275
|
+
break
|
276
|
+
|
277
|
+
# Check for suspicious string joins with list comprehensions
|
278
|
+
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and
|
279
|
+
node.func.attr == "join" and isinstance(node.args[0], ast.ListComp)):
|
280
|
+
suspicious_patterns = True
|
281
|
+
break
|
282
|
+
|
283
|
+
# Check for base64 decoding
|
284
|
+
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and
|
285
|
+
node.func.attr in ["b64decode", "b64encode", "b32decode", "b32encode", "b16decode", "b16encode"]):
|
286
|
+
suspicious_patterns = True
|
287
|
+
break
|
288
|
+
|
289
|
+
# Check for string formatting that might be used to build dangerous code
|
290
|
+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == "format":
|
291
|
+
suspicious_patterns = True
|
292
|
+
break
|
293
|
+
|
294
|
+
return suspicious_patterns
|
295
|
+
|
296
|
+
|
297
|
+
def validate_code_safety(code: str, *, authorized_imports: Sequence[str] | None = None,
|
298
|
+
authorized_functions: Sequence[str] | None = None, trusted_code: bool = False) -> None:
|
299
|
+
"""Static validation of user code.
|
300
|
+
|
301
|
+
Parameters
|
302
|
+
----------
|
303
|
+
code
|
304
|
+
The user supplied source code (single string or multi-line).
|
305
|
+
authorized_imports
|
306
|
+
Optional white-list restricting which modules may be imported. If
|
307
|
+
*None* every module that is not in :pydata:`DANGEROUS_MODULES` is
|
308
|
+
allowed. Wildcards are supported – e.g. ``["numpy.*"]`` allows any
|
309
|
+
sub-package of *numpy*.
|
310
|
+
authorized_functions
|
311
|
+
Optional white-list of dangerous functions that are allowed.
|
312
|
+
trusted_code
|
313
|
+
If True, skip security checks. This should only be used for code that is part of the
|
314
|
+
framework, developer-provided tools, or default executed code.
|
315
|
+
"""
|
316
|
+
# Skip security checks for trusted code
|
317
|
+
if trusted_code:
|
318
|
+
return
|
319
|
+
|
320
|
+
try:
|
321
|
+
tree = ast.parse(code, mode="exec")
|
322
|
+
except SyntaxError:
|
323
|
+
# If the code does not even parse we leave the error handling to the
|
324
|
+
# caller (who will attempt to compile / execute the code later on).
|
325
|
+
return
|
326
|
+
|
327
|
+
blocked: set[str] = set()
|
328
|
+
|
329
|
+
# Convert authorized_imports to a set if it's not None and not a boolean
|
330
|
+
combined_allowed = None
|
331
|
+
if authorized_imports is not None and not isinstance(authorized_imports, bool):
|
332
|
+
combined_allowed = set(list(authorized_imports) + list(ESSENTIAL_MODULES))
|
333
|
+
|
334
|
+
|
335
|
+
for node in _iter_import_nodes(tree):
|
336
|
+
for root in _extract_module_roots(node):
|
337
|
+
|
338
|
+
# Check if module is explicitly allowed
|
339
|
+
if combined_allowed is not None:
|
340
|
+
allowed = _is_allowed(root, combined_allowed)
|
341
|
+
else:
|
342
|
+
# If no explicit allow-list, only allow if not in DANGEROUS_MODULES
|
343
|
+
allowed = root not in DANGEROUS_MODULES
|
344
|
+
|
345
|
+
if root in DANGEROUS_MODULES and allowed and combined_allowed is not None:
|
346
|
+
warnings.warn(
|
347
|
+
f"⚠️ Importing dangerous module '{root}' was allowed due to authorized_imports configuration.",
|
348
|
+
stacklevel=2,
|
349
|
+
)
|
350
|
+
|
351
|
+
# Block dangerous modules unless explicitly allowed
|
352
|
+
if root in DANGEROUS_MODULES and not allowed:
|
353
|
+
blocked.add(root)
|
354
|
+
# If there is an explicit allow-list, block everything not on it
|
355
|
+
elif authorized_imports is not None and not isinstance(authorized_imports, bool) and not allowed and root not in ESSENTIAL_MODULES:
|
356
|
+
blocked.add(root)
|
357
|
+
|
358
|
+
# ------------------------------------------------------------------
|
359
|
+
# Detect direct calls to __import__ (e.g. __import__("os")) in *untrusted* code
|
360
|
+
# ------------------------------------------------------------------
|
361
|
+
for _node in ast.walk(tree):
|
362
|
+
if isinstance(_node, ast.Call):
|
363
|
+
# Pattern: __import__(...)
|
364
|
+
if isinstance(_node.func, ast.Name) and _node.func.id == "__import__":
|
365
|
+
# Check if it's a direct call to the built-in __import__
|
366
|
+
raise ValueError("SECURITY VIOLATION: Usage of __import__ is not allowed in untrusted code.")
|
367
|
+
# Pattern: builtins.__import__(...)
|
368
|
+
if (
|
369
|
+
isinstance(_node.func, ast.Attribute)
|
370
|
+
and isinstance(_node.func.value, ast.Name)
|
371
|
+
and _node.func.attr == "__import__"
|
372
|
+
and _node.func.value.id == "builtins"
|
373
|
+
):
|
374
|
+
raise ValueError("SECURITY VIOLATION: Usage of builtins.__import__ is not allowed in untrusted code.")
|
375
|
+
|
376
|
+
# ------------------------------------------------------------------
|
377
|
+
# Detect calls to dangerous functions (e.g. exec, eval) in *untrusted* code
|
378
|
+
# ------------------------------------------------------------------
|
379
|
+
dangerous_calls = _check_for_dangerous_function_calls(tree, authorized_functions)
|
380
|
+
if dangerous_calls:
|
381
|
+
offenders = ", ".join(sorted(dangerous_calls))
|
382
|
+
raise ValueError(f"SECURITY VIOLATION: Usage of dangerous function(s) {offenders} is not allowed in untrusted code.")
|
383
|
+
|
384
|
+
# ------------------------------------------------------------------
|
385
|
+
# Detect string obfuscation techniques that might be used to bypass security
|
386
|
+
# ------------------------------------------------------------------
|
387
|
+
if _detect_string_obfuscation(tree):
|
388
|
+
raise ValueError("SECURITY VIOLATION: Suspicious string manipulation detected that could be used to bypass security.")
|
389
|
+
|
390
|
+
if blocked:
|
391
|
+
offenders = ", ".join(sorted(blocked))
|
392
|
+
msg = f"SECURITY VIOLATION: Importing module(s) {offenders} is not allowed."
|
393
|
+
if authorized_imports is not None and not isinstance(authorized_imports, bool):
|
394
|
+
msg += " Allowed imports are: " + ", ".join(sorted(authorized_imports))
|
395
|
+
raise ValueError(msg)
|
396
|
+
|
397
|
+
|
398
|
+
# ---------------------------------------------------------------------------
|
399
|
+
# Runtime import hook
|
400
|
+
# ---------------------------------------------------------------------------
|
401
|
+
|
402
|
+
def install_import_hook(
|
403
|
+
*,
|
404
|
+
blocked_modules: Set[str] | None = None,
|
405
|
+
authorized_imports: Sequence[str] | None = None,
|
406
|
+
trusted_code: bool = False,
|
407
|
+
) -> None:
|
408
|
+
"""Monkey-patch the built-in ``__import__`` to deny run-time imports.
|
409
|
+
|
410
|
+
The hook is *process-wide* but extremely cheap to install. It simply
|
411
|
+
checks the *root* package name against the provided *blocked_modules*
|
412
|
+
(defaults to :pydata:`DANGEROUS_MODULES`) and raises ``ImportError`` if the
|
413
|
+
import should be denied.
|
414
|
+
|
415
|
+
Calling this function **multiple times** is safe – only the first call
|
416
|
+
installs the wrapper, subsequent calls are ignored.
|
417
|
+
|
418
|
+
Parameters
|
419
|
+
----------
|
420
|
+
blocked_modules
|
421
|
+
Set of module names to block. Defaults to DANGEROUS_MODULES.
|
422
|
+
authorized_imports
|
423
|
+
Optional white-list restricting which modules may be imported.
|
424
|
+
trusted_code
|
425
|
+
If True, skip security checks. This should only be used for code that is part of the
|
426
|
+
framework, developer-provided tools, or default executed code.
|
427
|
+
"""
|
428
|
+
# Skip security checks for trusted code
|
429
|
+
if trusted_code:
|
430
|
+
return
|
431
|
+
|
432
|
+
blocked_modules = blocked_modules or DANGEROUS_MODULES
|
433
|
+
|
434
|
+
# Convert authorized_imports to a set if it's not None and not a boolean
|
435
|
+
authorized_set = set(authorized_imports) if authorized_imports is not None and not isinstance(authorized_imports, bool) else None
|
436
|
+
|
437
|
+
# Create a combined set for allowed modules (essential + authorized)
|
438
|
+
combined_allowed = None
|
439
|
+
if authorized_set is not None:
|
440
|
+
combined_allowed = set(list(authorized_set) + list(ESSENTIAL_MODULES))
|
441
|
+
|
442
|
+
# Check if we have already installed the hook to avoid double-wrapping.
|
443
|
+
if getattr(builtins, "__tinyagent_import_hook_installed", False):
|
444
|
+
return
|
445
|
+
|
446
|
+
original_import = builtins.__import__
|
447
|
+
|
448
|
+
def _safe_import(
|
449
|
+
name: str,
|
450
|
+
globals=None,
|
451
|
+
locals=None,
|
452
|
+
fromlist=(),
|
453
|
+
level: int = 0,
|
454
|
+
): # type: ignore[override]
|
455
|
+
root = name.split(".")[0]
|
456
|
+
|
457
|
+
# Check if module is explicitly allowed
|
458
|
+
if combined_allowed is not None:
|
459
|
+
allowed = _is_allowed(root, combined_allowed)
|
460
|
+
else:
|
461
|
+
# If no explicit allow-list, only allow if not in blocked_modules
|
462
|
+
allowed = root not in blocked_modules
|
463
|
+
|
464
|
+
if root in blocked_modules and allowed and authorized_set is not None:
|
465
|
+
warnings.warn(
|
466
|
+
f"⚠️ Importing dangerous module '{root}' was allowed due to authorized_imports configuration.",
|
467
|
+
stacklevel=2,
|
468
|
+
)
|
469
|
+
elif root in blocked_modules and not allowed:
|
470
|
+
error_msg = f"SECURITY VIOLATION: Import of module '{name}' is blocked by TinyAgent security policy"
|
471
|
+
if authorized_set is not None:
|
472
|
+
error_msg += f". Allowed imports are: {', '.join(sorted(authorized_set))}"
|
473
|
+
raise ImportError(error_msg)
|
474
|
+
elif authorized_set is not None and not allowed and root not in ESSENTIAL_MODULES:
|
475
|
+
error_msg = f"SECURITY VIOLATION: Import of module '{name}' is not in the authorized imports list"
|
476
|
+
if authorized_set:
|
477
|
+
error_msg += f": {', '.join(sorted(authorized_set))}"
|
478
|
+
raise ImportError(error_msg)
|
479
|
+
|
480
|
+
return original_import(name, globals, locals, fromlist, level)
|
481
|
+
|
482
|
+
builtins.__import__ = _safe_import # type: ignore[assignment]
|
483
|
+
setattr(builtins, "__tinyagent_import_hook_installed", True)
|
484
|
+
|
485
|
+
|
486
|
+
# ---------------------------------------------------------------------------
|
487
|
+
# Runtime function hook
|
488
|
+
# ---------------------------------------------------------------------------
|
489
|
+
|
490
|
+
@contextlib.contextmanager
|
491
|
+
def function_safety_context(
|
492
|
+
*,
|
493
|
+
blocked_functions: Set[str] | None = None,
|
494
|
+
authorized_functions: Sequence[str] | None = None,
|
495
|
+
trusted_code: bool = False,
|
496
|
+
):
|
497
|
+
"""
|
498
|
+
Context manager for safely executing code with dangerous functions blocked.
|
499
|
+
|
500
|
+
Parameters
|
501
|
+
----------
|
502
|
+
blocked_functions
|
503
|
+
Set of function names to block. Defaults to RUNTIME_BLOCKED_FUNCTIONS.
|
504
|
+
authorized_functions
|
505
|
+
Optional white-list of dangerous functions that are allowed.
|
506
|
+
trusted_code
|
507
|
+
If True, skip security checks. This should only be used for code that is part of the
|
508
|
+
framework, developer-provided tools, or default executed code.
|
509
|
+
"""
|
510
|
+
if trusted_code:
|
511
|
+
yield
|
512
|
+
return
|
513
|
+
|
514
|
+
# Install the function hook
|
515
|
+
blocked_functions = blocked_functions or RUNTIME_BLOCKED_FUNCTIONS
|
516
|
+
|
517
|
+
# Convert authorized_functions to a set if it's not None and not a boolean
|
518
|
+
authorized_set = set(authorized_functions) if authorized_functions is not None and not isinstance(authorized_functions, bool) else set()
|
519
|
+
|
520
|
+
# Store original functions
|
521
|
+
original_functions = {}
|
522
|
+
|
523
|
+
# Replace dangerous functions with safe versions
|
524
|
+
for func_name in blocked_functions:
|
525
|
+
# Only block functions that exist in builtins
|
526
|
+
if hasattr(builtins, func_name) and func_name not in authorized_set:
|
527
|
+
original_functions[func_name] = getattr(builtins, func_name)
|
528
|
+
|
529
|
+
# Create a closure to capture the function name
|
530
|
+
def make_safe_function(name):
|
531
|
+
def safe_function(*args, **kwargs):
|
532
|
+
error_msg = f"SECURITY VIOLATION: Function '{name}' is blocked by TinyAgent security policy"
|
533
|
+
if authorized_functions and not isinstance(authorized_functions, bool) and isinstance(authorized_functions, (list, tuple, set)):
|
534
|
+
error_msg += f". Allowed functions are: {', '.join(sorted(authorized_set))}"
|
535
|
+
raise RuntimeError(error_msg)
|
536
|
+
return safe_function
|
537
|
+
|
538
|
+
# Replace the function
|
539
|
+
setattr(builtins, func_name, make_safe_function(func_name))
|
540
|
+
|
541
|
+
try:
|
542
|
+
yield
|
543
|
+
finally:
|
544
|
+
# Restore original functions
|
545
|
+
for func_name, original_func in original_functions.items():
|
546
|
+
setattr(builtins, func_name, original_func)
|
@@ -62,6 +62,7 @@ class TinyCodeAgent:
|
|
62
62
|
self.user_variables = user_variables or {}
|
63
63
|
self.pip_packages = pip_packages or []
|
64
64
|
self.local_execution = local_execution
|
65
|
+
self.provider = provider # Store provider type for reuse
|
65
66
|
|
66
67
|
# Create the code execution provider
|
67
68
|
self.code_provider = self._create_provider(provider, self.provider_config)
|
@@ -96,8 +97,13 @@ class TinyCodeAgent:
|
|
96
97
|
config_pip_packages = config.get("pip_packages", [])
|
97
98
|
final_pip_packages = list(set(self.pip_packages + config_pip_packages))
|
98
99
|
|
100
|
+
# Merge authorized_imports from both sources (direct parameter and provider_config)
|
101
|
+
config_authorized_imports = config.get("authorized_imports", [])
|
102
|
+
final_authorized_imports = list(set(self.authorized_imports + config_authorized_imports))
|
103
|
+
|
99
104
|
final_config = config.copy()
|
100
105
|
final_config["pip_packages"] = final_pip_packages
|
106
|
+
final_config["authorized_imports"] = final_authorized_imports
|
101
107
|
|
102
108
|
return ModalProvider(
|
103
109
|
log_manager=self.log_manager,
|
@@ -258,6 +264,14 @@ class TinyCodeAgent:
|
|
258
264
|
result = await self.code_provider.execute_python(code_lines, timeout)
|
259
265
|
return str(result)
|
260
266
|
except Exception as e:
|
267
|
+
print("!"*100)
|
268
|
+
COLOR = {
|
269
|
+
"RED": "\033[91m",
|
270
|
+
"ENDC": "\033[0m",
|
271
|
+
}
|
272
|
+
print(f"{COLOR['RED']}{str(e)}{COLOR['ENDC']}")
|
273
|
+
print(f"{COLOR['RED']}{traceback.format_exc()}{COLOR['ENDC']}")
|
274
|
+
print("!"*100)
|
261
275
|
return f"Error executing code: {str(e)}"
|
262
276
|
|
263
277
|
self.agent.add_tool(run_python)
|
@@ -441,6 +455,69 @@ class TinyCodeAgent:
|
|
441
455
|
"""
|
442
456
|
return self.pip_packages.copy()
|
443
457
|
|
458
|
+
def add_authorized_imports(self, imports: List[str]):
|
459
|
+
"""
|
460
|
+
Add additional authorized imports to the execution environment.
|
461
|
+
|
462
|
+
Args:
|
463
|
+
imports: List of import names to authorize
|
464
|
+
"""
|
465
|
+
self.authorized_imports.extend(imports)
|
466
|
+
self.authorized_imports = list(set(self.authorized_imports)) # Remove duplicates
|
467
|
+
|
468
|
+
# Update the provider with the new authorized imports
|
469
|
+
# This requires recreating the provider
|
470
|
+
print("⚠️ Warning: Adding authorized imports after initialization requires recreating the Modal environment.")
|
471
|
+
print(" For better performance, set authorized_imports during TinyCodeAgent initialization.")
|
472
|
+
|
473
|
+
# Recreate the provider with new authorized imports
|
474
|
+
self.code_provider = self._create_provider(self.provider, self.provider_config)
|
475
|
+
|
476
|
+
# Re-set user variables if they exist
|
477
|
+
if self.user_variables:
|
478
|
+
self.code_provider.set_user_variables(self.user_variables)
|
479
|
+
|
480
|
+
# Rebuild system prompt to include new authorized imports
|
481
|
+
self.system_prompt = self._build_system_prompt()
|
482
|
+
# Update the agent's system prompt
|
483
|
+
self.agent.system_prompt = self.system_prompt
|
484
|
+
|
485
|
+
def get_authorized_imports(self) -> List[str]:
|
486
|
+
"""
|
487
|
+
Get a copy of current authorized imports.
|
488
|
+
|
489
|
+
Returns:
|
490
|
+
List of authorized imports
|
491
|
+
"""
|
492
|
+
return self.authorized_imports.copy()
|
493
|
+
|
494
|
+
def remove_authorized_import(self, import_name: str):
|
495
|
+
"""
|
496
|
+
Remove an authorized import.
|
497
|
+
|
498
|
+
Args:
|
499
|
+
import_name: Import name to remove
|
500
|
+
"""
|
501
|
+
if import_name in self.authorized_imports:
|
502
|
+
self.authorized_imports.remove(import_name)
|
503
|
+
|
504
|
+
# Update the provider with the new authorized imports
|
505
|
+
# This requires recreating the provider
|
506
|
+
print("⚠️ Warning: Removing authorized imports after initialization requires recreating the Modal environment.")
|
507
|
+
print(" For better performance, set authorized_imports during TinyCodeAgent initialization.")
|
508
|
+
|
509
|
+
# Recreate the provider with updated authorized imports
|
510
|
+
self.code_provider = self._create_provider(self.provider, self.provider_config)
|
511
|
+
|
512
|
+
# Re-set user variables if they exist
|
513
|
+
if self.user_variables:
|
514
|
+
self.code_provider.set_user_variables(self.user_variables)
|
515
|
+
|
516
|
+
# Rebuild system prompt to reflect updated authorized imports
|
517
|
+
self.system_prompt = self._build_system_prompt()
|
518
|
+
# Update the agent's system prompt
|
519
|
+
self.agent.system_prompt = self.system_prompt
|
520
|
+
|
444
521
|
async def close(self):
|
445
522
|
"""Clean up resources."""
|
446
523
|
await self.code_provider.cleanup()
|
@@ -498,6 +575,7 @@ async def run_example():
|
|
498
575
|
user_variables={
|
499
576
|
"sample_data": [1, 2, 3, 4, 5, 10, 15, 20]
|
500
577
|
},
|
578
|
+
authorized_imports=["tinyagent", "gradio", "requests", "numpy", "pandas"], # Explicitly specify authorized imports
|
501
579
|
local_execution=False # Remote execution via Modal (default)
|
502
580
|
)
|
503
581
|
|
@@ -524,6 +602,7 @@ async def run_example():
|
|
524
602
|
user_variables={
|
525
603
|
"sample_data": [1, 2, 3, 4, 5, 10, 15, 20]
|
526
604
|
},
|
605
|
+
authorized_imports=["tinyagent", "gradio", "requests"], # More restricted imports for local execution
|
527
606
|
local_execution=True # Local execution
|
528
607
|
)
|
529
608
|
|
@@ -550,6 +629,18 @@ async def run_example():
|
|
550
629
|
agent_remote.add_code_tool(validator)
|
551
630
|
agent_local.add_code_tool(validator)
|
552
631
|
|
632
|
+
# Demonstrate adding authorized imports dynamically
|
633
|
+
print("\n" + "="*80)
|
634
|
+
print("🔧 Testing with dynamically added authorized imports")
|
635
|
+
agent_remote.add_authorized_imports(["matplotlib", "seaborn"])
|
636
|
+
|
637
|
+
# Test with visualization libraries
|
638
|
+
viz_prompt = "Create a simple plot of the sample_data and save it as a base64 encoded image string."
|
639
|
+
|
640
|
+
response_viz = await agent_remote.run(viz_prompt)
|
641
|
+
print("Remote Agent Visualization Response:")
|
642
|
+
print(response_viz)
|
643
|
+
|
553
644
|
print("\n" + "="*80)
|
554
645
|
print("🔧 Testing with dynamically added tools")
|
555
646
|
|
tinyagent/code_agent/utils.py
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
import sys
|
2
2
|
import cloudpickle
|
3
|
-
from typing import Dict, Any
|
3
|
+
from typing import Dict, Any, List
|
4
|
+
from .safety import validate_code_safety, function_safety_context
|
4
5
|
|
5
6
|
|
6
7
|
def clean_response(resp: Dict[str, Any]) -> Dict[str, Any]:
|
@@ -40,7 +41,14 @@ def make_session_blob(ns: dict) -> bytes:
|
|
40
41
|
return cloudpickle.dumps(clean)
|
41
42
|
|
42
43
|
|
43
|
-
def _run_python(
|
44
|
+
def _run_python(
|
45
|
+
code: str,
|
46
|
+
globals_dict: Dict[str, Any] | None = None,
|
47
|
+
locals_dict: Dict[str, Any] | None = None,
|
48
|
+
authorized_imports: List[str] | None = None,
|
49
|
+
authorized_functions: List[str] | None = None,
|
50
|
+
trusted_code: bool = False,
|
51
|
+
):
|
44
52
|
"""
|
45
53
|
Execute Python code in a controlled environment with proper error handling.
|
46
54
|
|
@@ -48,6 +56,9 @@ def _run_python(code: str, globals_dict: Dict[str, Any] = None, locals_dict: Dic
|
|
48
56
|
code: Python code to execute
|
49
57
|
globals_dict: Global variables dictionary
|
50
58
|
locals_dict: Local variables dictionary
|
59
|
+
authorized_imports: List of authorized imports that user code may access. Wildcards (e.g. "numpy.*") are supported. A value of None disables the allow-list and only blocks dangerous modules.
|
60
|
+
authorized_functions: List of authorized dangerous functions that user code may access. A value of None disables the allow-list and blocks all dangerous functions.
|
61
|
+
trusted_code: If True, skip security checks. Should only be used for framework code, tools, or default executed code.
|
51
62
|
|
52
63
|
Returns:
|
53
64
|
Dictionary containing execution results
|
@@ -56,16 +67,27 @@ def _run_python(code: str, globals_dict: Dict[str, Any] = None, locals_dict: Dic
|
|
56
67
|
import traceback
|
57
68
|
import io
|
58
69
|
import ast
|
59
|
-
|
70
|
+
import builtins # Needed for import hook
|
71
|
+
import sys
|
72
|
+
|
73
|
+
# ------------------------------------------------------------------
|
74
|
+
# 1. Static safety analysis – refuse code containing dangerous imports or functions
|
75
|
+
# ------------------------------------------------------------------
|
76
|
+
validate_code_safety(code, authorized_imports=authorized_imports,
|
77
|
+
authorized_functions=authorized_functions, trusted_code=trusted_code)
|
78
|
+
|
60
79
|
# Make copies to avoid mutating the original parameters
|
61
80
|
globals_dict = globals_dict or {}
|
62
81
|
locals_dict = locals_dict or {}
|
63
82
|
updated_globals = globals_dict.copy()
|
64
83
|
updated_locals = locals_dict.copy()
|
65
84
|
|
66
|
-
#
|
67
|
-
#
|
68
|
-
|
85
|
+
# Only pre-import a **minimal** set of safe modules so that common helper
|
86
|
+
# functions work out of the box without giving user code access to the
|
87
|
+
# full standard library. Anything outside this list must be imported
|
88
|
+
# explicitly by the user – and will be blocked by the safety layer above
|
89
|
+
# if considered dangerous.
|
90
|
+
essential_modules = ['requests', 'json', 'time', 'datetime', 're', 'random', 'math','cloudpickle']
|
69
91
|
|
70
92
|
for module_name in essential_modules:
|
71
93
|
try:
|
@@ -75,12 +97,30 @@ def _run_python(code: str, globals_dict: Dict[str, Any] = None, locals_dict: Dic
|
|
75
97
|
except ImportError:
|
76
98
|
print(f"⚠️ Warning: {module_name} module not available")
|
77
99
|
|
100
|
+
# Variable to store print output
|
101
|
+
output_buffer = []
|
102
|
+
|
103
|
+
# Create a custom print function that captures output
|
104
|
+
def custom_print(*args, **kwargs):
|
105
|
+
# Get the sep and end kwargs, defaulting to ' ' and '\n'
|
106
|
+
sep = kwargs.get('sep', ' ')
|
107
|
+
end = kwargs.get('end', '\n')
|
108
|
+
|
109
|
+
# Convert all arguments to strings and join them
|
110
|
+
output = sep.join(str(arg) for arg in args) + end
|
111
|
+
|
112
|
+
# Store the output
|
113
|
+
output_buffer.append(output)
|
114
|
+
|
115
|
+
# Add the custom print function to the globals
|
116
|
+
#updated_globals['print'] = custom_print
|
117
|
+
|
118
|
+
# Parse the code
|
78
119
|
tree = ast.parse(code, mode="exec")
|
79
120
|
compiled = compile(tree, filename="<ast>", mode="exec")
|
80
121
|
stdout_buf = io.StringIO()
|
81
|
-
stderr_buf = io.StringIO()
|
82
|
-
|
83
|
-
# Execute with stdout+stderr capture and exception handling
|
122
|
+
stderr_buf = io.StringIO()
|
123
|
+
# Execute with exception handling
|
84
124
|
error_traceback = None
|
85
125
|
output = None
|
86
126
|
|
@@ -92,8 +132,15 @@ def _run_python(code: str, globals_dict: Dict[str, Any] = None, locals_dict: Dic
|
|
92
132
|
merged_globals = updated_globals.copy()
|
93
133
|
merged_globals.update(updated_locals)
|
94
134
|
|
135
|
+
# Add 'exec' to authorized_functions for internal use
|
136
|
+
internal_authorized_functions = ['exec','eval']
|
137
|
+
if authorized_functions is not None and not isinstance(authorized_functions, bool):
|
138
|
+
internal_authorized_functions.extend(authorized_functions)
|
139
|
+
|
95
140
|
# Execute with only globals - this fixes generator expression scoping issues
|
96
|
-
|
141
|
+
# Use the function_safety_context to block dangerous functions during execution
|
142
|
+
with function_safety_context(authorized_functions=internal_authorized_functions, trusted_code=trusted_code):
|
143
|
+
output = exec(compiled, merged_globals)
|
97
144
|
|
98
145
|
# Update both dictionaries with any new variables created during execution
|
99
146
|
for key, value in merged_globals.items():
|
@@ -106,6 +153,8 @@ def _run_python(code: str, globals_dict: Dict[str, Any] = None, locals_dict: Dic
|
|
106
153
|
# Capture the full traceback as a string
|
107
154
|
error_traceback = traceback.format_exc()
|
108
155
|
|
156
|
+
# Join all captured output
|
157
|
+
#printed_output = ''.join(output_buffer)
|
109
158
|
printed_output = stdout_buf.getvalue()
|
110
159
|
stderr_output = stderr_buf.getvalue()
|
111
160
|
error_traceback_output = error_traceback
|
@@ -5,6 +5,7 @@ import os
|
|
5
5
|
import re
|
6
6
|
import shutil
|
7
7
|
import time
|
8
|
+
import io
|
8
9
|
from pathlib import Path
|
9
10
|
from typing import Any, Dict, List, Optional, Set, Union
|
10
11
|
|
@@ -36,6 +37,7 @@ class GradioCallback:
|
|
36
37
|
show_thinking: bool = True,
|
37
38
|
show_tool_calls: bool = True,
|
38
39
|
logger: Optional[logging.Logger] = None,
|
40
|
+
log_manager: Optional[Any] = None,
|
39
41
|
):
|
40
42
|
"""
|
41
43
|
Initialize the Gradio callback.
|
@@ -46,6 +48,7 @@ class GradioCallback:
|
|
46
48
|
show_thinking: Whether to show the thinking process
|
47
49
|
show_tool_calls: Whether to show tool calls
|
48
50
|
logger: Optional logger to use
|
51
|
+
log_manager: Optional LoggingManager instance to capture logs from
|
49
52
|
"""
|
50
53
|
self.logger = logger or logging.getLogger(__name__)
|
51
54
|
self.show_thinking = show_thinking
|
@@ -81,6 +84,37 @@ class GradioCallback:
|
|
81
84
|
# References to Gradio UI components (will be set in create_app)
|
82
85
|
self._chatbot_component = None
|
83
86
|
self._token_usage_component = None
|
87
|
+
|
88
|
+
# Log stream for displaying logs in the UI
|
89
|
+
self.log_stream = io.StringIO()
|
90
|
+
self._log_component = None
|
91
|
+
|
92
|
+
# Setup logging
|
93
|
+
self.log_manager = log_manager
|
94
|
+
if log_manager:
|
95
|
+
# Create a handler that writes to our StringIO stream
|
96
|
+
self.log_handler = logging.StreamHandler(self.log_stream)
|
97
|
+
self.log_handler.setFormatter(
|
98
|
+
logging.Formatter('%(asctime)s - %(levelname)s - %(name)s - %(message)s')
|
99
|
+
)
|
100
|
+
self.log_handler.setLevel(logging.DEBUG)
|
101
|
+
|
102
|
+
# Add the handler to the LoggingManager
|
103
|
+
log_manager.configure_handler(
|
104
|
+
self.log_handler,
|
105
|
+
format_string='%(asctime)s - %(levelname)s - %(name)s - %(message)s',
|
106
|
+
level=logging.DEBUG
|
107
|
+
)
|
108
|
+
self.logger.debug("Added log handler to LoggingManager")
|
109
|
+
elif logger:
|
110
|
+
# Fall back to single logger if no LoggingManager is provided
|
111
|
+
self.log_handler = logging.StreamHandler(self.log_stream)
|
112
|
+
self.log_handler.setFormatter(
|
113
|
+
logging.Formatter('%(asctime)s - %(levelname)s - %(name)s - %(message)s')
|
114
|
+
)
|
115
|
+
self.log_handler.setLevel(logging.DEBUG)
|
116
|
+
logger.addHandler(self.log_handler)
|
117
|
+
self.logger.debug("Added log handler to logger")
|
84
118
|
|
85
119
|
self.logger.debug("GradioCallback initialized")
|
86
120
|
|
@@ -525,7 +559,7 @@ class GradioCallback:
|
|
525
559
|
typing_message_index = len(chatbot_history) - 1
|
526
560
|
|
527
561
|
# Initial yield to show user message and typing indicator
|
528
|
-
yield chatbot_history, self._get_token_usage_text()
|
562
|
+
yield chatbot_history, self._get_token_usage_text(), self.log_stream.getvalue() if self._log_component else None
|
529
563
|
|
530
564
|
# Kick off the agent in the background
|
531
565
|
loop = asyncio.get_event_loop()
|
@@ -632,9 +666,10 @@ class GradioCallback:
|
|
632
666
|
del in_progress_tool_calls[tid]
|
633
667
|
self.logger.debug(f"Updated tool call to completed: {tname}")
|
634
668
|
|
635
|
-
# yield updated history + token usage
|
669
|
+
# yield updated history + token usage + logs
|
636
670
|
token_text = self._get_token_usage_text()
|
637
|
-
|
671
|
+
logs = self.log_stream.getvalue() if self._log_component else None
|
672
|
+
yield chatbot_history, token_text, logs
|
638
673
|
self.last_update_yield_time = now
|
639
674
|
|
640
675
|
await asyncio.sleep(update_interval)
|
@@ -657,8 +692,9 @@ class GradioCallback:
|
|
657
692
|
)
|
658
693
|
self.logger.debug(f"Added final result: {final_text[:50]}...")
|
659
694
|
|
660
|
-
# final token usage
|
661
|
-
|
695
|
+
# final token usage and logs
|
696
|
+
logs = self.log_stream.getvalue() if self._log_component else None
|
697
|
+
yield chatbot_history, self._get_token_usage_text(), logs
|
662
698
|
|
663
699
|
def _format_response(self, response_text):
|
664
700
|
"""
|
@@ -839,6 +875,22 @@ class GradioCallback:
|
|
839
875
|
# Clear button
|
840
876
|
clear_btn = gr.Button("Clear Conversation")
|
841
877
|
|
878
|
+
# Log accordion - similar to the example provided
|
879
|
+
with gr.Accordion("Agent Logs", open=False) as log_accordion:
|
880
|
+
self._log_component = gr.Code(
|
881
|
+
label="Live Logs",
|
882
|
+
lines=15,
|
883
|
+
interactive=False,
|
884
|
+
value=self.log_stream.getvalue()
|
885
|
+
)
|
886
|
+
refresh_logs_btn = gr.Button("🔄 Refresh Logs")
|
887
|
+
refresh_logs_btn.click(
|
888
|
+
fn=lambda: self.log_stream.getvalue(),
|
889
|
+
inputs=None,
|
890
|
+
outputs=[self._log_component],
|
891
|
+
queue=False
|
892
|
+
)
|
893
|
+
|
842
894
|
# Store processed input temporarily between steps
|
843
895
|
processed_input_state = gr.State("")
|
844
896
|
|
@@ -859,7 +911,7 @@ class GradioCallback:
|
|
859
911
|
# 3. Run the main interaction loop (this yields updates)
|
860
912
|
fn=self.interact_with_agent,
|
861
913
|
inputs=[processed_input_state, self._chatbot_component],
|
862
|
-
outputs=[self._chatbot_component, self._token_usage_component], # Update chat and
|
914
|
+
outputs=[self._chatbot_component, self._token_usage_component, self._log_component], # Update chat, tokens, and logs
|
863
915
|
queue=True # Explicitly enable queue for this async generator
|
864
916
|
).then(
|
865
917
|
# 4. Re-enable the button after interaction finishes
|
@@ -885,7 +937,7 @@ class GradioCallback:
|
|
885
937
|
# 3. Run the main interaction loop (this yields updates)
|
886
938
|
fn=self.interact_with_agent,
|
887
939
|
inputs=[processed_input_state, self._chatbot_component],
|
888
|
-
outputs=[self._chatbot_component, self._token_usage_component], # Update chat and
|
940
|
+
outputs=[self._chatbot_component, self._token_usage_component, self._log_component], # Update chat, tokens, and logs
|
889
941
|
queue=True # Explicitly enable queue for this async generator
|
890
942
|
).then(
|
891
943
|
# 4. Re-enable the button after interaction finishes
|
@@ -899,8 +951,8 @@ class GradioCallback:
|
|
899
951
|
clear_btn.click(
|
900
952
|
fn=self.clear_conversation,
|
901
953
|
inputs=None, # No inputs needed
|
902
|
-
# Outputs: Clear chatbot
|
903
|
-
outputs=[self._chatbot_component, self._token_usage_component],
|
954
|
+
# Outputs: Clear chatbot, reset token text, and update logs
|
955
|
+
outputs=[self._chatbot_component, self._token_usage_component, self._log_component],
|
904
956
|
queue=False # Run quickly
|
905
957
|
)
|
906
958
|
|
@@ -917,6 +969,12 @@ class GradioCallback:
|
|
917
969
|
self.assistant_text_responses = []
|
918
970
|
self.token_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
919
971
|
self.is_running = False
|
972
|
+
|
973
|
+
# Clear log stream
|
974
|
+
if hasattr(self, 'log_stream'):
|
975
|
+
self.log_stream.seek(0)
|
976
|
+
self.log_stream.truncate(0)
|
977
|
+
self.logger.info("Log stream cleared")
|
920
978
|
|
921
979
|
# Completely reset the agent state with a new session
|
922
980
|
try:
|
@@ -965,8 +1023,9 @@ class GradioCallback:
|
|
965
1023
|
except Exception as e:
|
966
1024
|
self.logger.error(f"Failed to reset TinyAgent completely: {e}")
|
967
1025
|
|
968
|
-
# Return cleared UI components: empty chat + fresh token usage
|
969
|
-
|
1026
|
+
# Return cleared UI components: empty chat + fresh token usage + empty logs
|
1027
|
+
logs = self.log_stream.getvalue() if hasattr(self, 'log_stream') else ""
|
1028
|
+
return [], self._get_token_usage_text(), logs
|
970
1029
|
|
971
1030
|
def launch(self, agent, title="TinyAgent Chat", description=None, share=False, **kwargs):
|
972
1031
|
"""
|
@@ -1028,21 +1087,31 @@ async def run_example():
|
|
1028
1087
|
from tinyagent import TinyAgent # Assuming TinyAgent is importable
|
1029
1088
|
from tinyagent.hooks.logging_manager import LoggingManager # Assuming LoggingManager exists
|
1030
1089
|
|
1031
|
-
# --- Logging Setup (
|
1090
|
+
# --- Logging Setup (Similar to the example provided) ---
|
1032
1091
|
log_manager = LoggingManager(default_level=logging.INFO)
|
1033
1092
|
log_manager.set_levels({
|
1034
1093
|
'tinyagent.hooks.gradio_callback': logging.DEBUG,
|
1035
1094
|
'tinyagent.tiny_agent': logging.DEBUG,
|
1036
1095
|
'tinyagent.mcp_client': logging.DEBUG,
|
1096
|
+
'tinyagent.code_agent': logging.DEBUG,
|
1037
1097
|
})
|
1098
|
+
|
1099
|
+
# Console handler for terminal output
|
1038
1100
|
console_handler = logging.StreamHandler(sys.stdout)
|
1039
1101
|
log_manager.configure_handler(
|
1040
1102
|
console_handler,
|
1041
1103
|
format_string='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
1042
1104
|
level=logging.DEBUG
|
1043
1105
|
)
|
1106
|
+
|
1107
|
+
# The Gradio UI will automatically set up its own log handler
|
1108
|
+
# through the LoggingManager when we pass it to GradioCallback
|
1109
|
+
|
1110
|
+
# Get loggers for different components
|
1044
1111
|
ui_logger = log_manager.get_logger('tinyagent.hooks.gradio_callback')
|
1045
1112
|
agent_logger = log_manager.get_logger('tinyagent.tiny_agent')
|
1113
|
+
mcp_logger = log_manager.get_logger('tinyagent.mcp_client')
|
1114
|
+
|
1046
1115
|
ui_logger.info("--- Starting GradioCallback Example ---")
|
1047
1116
|
# --- End Logging Setup ---
|
1048
1117
|
|
@@ -1064,12 +1133,13 @@ async def run_example():
|
|
1064
1133
|
|
1065
1134
|
agent.add_tool(get_weather)
|
1066
1135
|
|
1067
|
-
# Create the Gradio callback
|
1136
|
+
# Create the Gradio callback with LoggingManager integration
|
1068
1137
|
gradio_ui = GradioCallback(
|
1069
1138
|
file_upload_folder=upload_folder,
|
1070
1139
|
show_thinking=True,
|
1071
1140
|
show_tool_calls=True,
|
1072
|
-
logger=ui_logger
|
1141
|
+
logger=ui_logger,
|
1142
|
+
log_manager=log_manager # Pass the LoggingManager for comprehensive logging
|
1073
1143
|
)
|
1074
1144
|
agent.add_callback(gradio_ui)
|
1075
1145
|
|
@@ -1084,25 +1154,9 @@ async def run_example():
|
|
1084
1154
|
ui_logger.error(f"Failed to connect to MCP servers: {e}", exc_info=True)
|
1085
1155
|
# Continue without servers - we still have the local get_weather tool
|
1086
1156
|
|
1087
|
-
#
|
1088
|
-
#app = gradio_ui.create_app(
|
1089
|
-
# agent,
|
1090
|
-
# title="TinyAgent Chat Interface",
|
1091
|
-
# description="Chat with TinyAgent. Try asking: 'Plan a trip to Toronto for 7 days in the next month.'",
|
1092
|
-
#)
|
1093
|
-
|
1094
|
-
# Configure the queue without extra parameters
|
1095
|
-
#app.queue()
|
1096
|
-
|
1097
|
-
# Launch the app in a way that doesn't block our event loop
|
1157
|
+
# Launch the Gradio interface
|
1098
1158
|
ui_logger.info("Launching Gradio interface...")
|
1099
1159
|
try:
|
1100
|
-
# Launch without blocking
|
1101
|
-
#app.launch(
|
1102
|
-
# share=False,
|
1103
|
-
# prevent_thread_lock=True, # Critical to not block our event loop
|
1104
|
-
# show_error=True
|
1105
|
-
#)
|
1106
1160
|
gradio_ui.launch(
|
1107
1161
|
agent,
|
1108
1162
|
title="TinyAgent Chat Interface",
|
@@ -1113,9 +1167,19 @@ async def run_example():
|
|
1113
1167
|
)
|
1114
1168
|
ui_logger.info("Gradio interface launched (non-blocking).")
|
1115
1169
|
|
1170
|
+
# Generate some log messages to demonstrate the log panel
|
1171
|
+
# These will appear in both the terminal and the Gradio UI log panel
|
1172
|
+
ui_logger.info("UI component initialized successfully")
|
1173
|
+
agent_logger.debug("Agent ready to process requests")
|
1174
|
+
mcp_logger.info("MCP connection established")
|
1175
|
+
|
1176
|
+
for i in range(3):
|
1177
|
+
ui_logger.info(f"Example log message {i+1} from UI logger")
|
1178
|
+
agent_logger.debug(f"Example debug message {i+1} from agent logger")
|
1179
|
+
mcp_logger.warning(f"Example warning {i+1} from MCP logger")
|
1180
|
+
await asyncio.sleep(1)
|
1181
|
+
|
1116
1182
|
# Keep the main event loop running to handle both Gradio and MCP operations
|
1117
|
-
# This is the key part - we need to keep our main event loop running
|
1118
|
-
# but also allow it to process both Gradio and MCP client operations
|
1119
1183
|
while True:
|
1120
1184
|
await asyncio.sleep(1) # More efficient than an Event().wait()
|
1121
1185
|
|
tinyagent/tiny_agent.py
CHANGED
@@ -725,13 +725,10 @@ class TinyAgent:
|
|
725
725
|
next_turn_should_call_tools = False
|
726
726
|
else:
|
727
727
|
# No tool calls in this message
|
728
|
-
|
729
|
-
#
|
730
|
-
|
731
|
-
|
732
|
-
return assistant_msg["content"] or ""
|
733
|
-
|
734
|
-
next_turn_should_call_tools = True
|
728
|
+
# If the model provides a direct answer without tool calls, we should return it
|
729
|
+
# This handles the case where the LLM gives a direct answer without using tools
|
730
|
+
await self._run_callbacks("agent_end", result=assistant_msg["content"] or "")
|
731
|
+
return assistant_msg["content"] or ""
|
735
732
|
|
736
733
|
num_turns += 1
|
737
734
|
if num_turns >= max_turns:
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: tinyagent-py
|
3
|
-
Version: 0.0.
|
3
|
+
Version: 0.0.12
|
4
4
|
Summary: TinyAgent with MCP Client, Code Agent (Thinking, Planning, and Executing in Python), and Extendable Hooks, Tiny but powerful
|
5
5
|
Author-email: Mahdi Golchin <golchin@askdev.ai>
|
6
6
|
Project-URL: Homepage, https://github.com/askbudi/tinyagent
|
@@ -1,20 +1,21 @@
|
|
1
1
|
tinyagent/__init__.py,sha256=-3ZN8unMZDrA366BET1HKp-fnFCyXCAD1fPVbHkJSsY,172
|
2
2
|
tinyagent/mcp_client.py,sha256=9dmLtJ8CTwKWKTH6K9z8CaCQuaicOH9ifAuNyX7Kdo0,6030
|
3
3
|
tinyagent/memory_manager.py,sha256=tAaZZdxBJ235wJIyr04n3f2Damok4s2UXunTtur_p-4,44916
|
4
|
-
tinyagent/tiny_agent.py,sha256=
|
4
|
+
tinyagent/tiny_agent.py,sha256=X-rh8gCxeVSNS4RJ9vdtqTKf8ksDGrXsV1jwprW6Fj8,41049
|
5
5
|
tinyagent/code_agent/__init__.py,sha256=YSOblSwRS1QcAYUu--GvF4fKeQX1KRTj9P8CWySY3pY,327
|
6
6
|
tinyagent/code_agent/example.py,sha256=qC6i3auUT1YwXS9WK1Ovq-9oDOUgzRxDegYdlVcVcfA,5861
|
7
7
|
tinyagent/code_agent/helper.py,sha256=oZnpo-_H3cB12LxNN7Ztd-31EiUcuI2UpWP69xuF8oE,7205
|
8
|
-
tinyagent/code_agent/modal_sandbox.py,sha256=
|
9
|
-
tinyagent/code_agent/
|
10
|
-
tinyagent/code_agent/
|
8
|
+
tinyagent/code_agent/modal_sandbox.py,sha256=RcQ5a-UFyqV7xSHnttpgAQQ-mNWk-9Z0tb836ua7C0E,16381
|
9
|
+
tinyagent/code_agent/safety.py,sha256=WHad2ypzfsdKnXG6FcgXcgGiMC-H4KTmOzhP9S9i3Zw,22209
|
10
|
+
tinyagent/code_agent/tiny_code_agent.py,sha256=r3c_irn5RJp9qNewhlOLFaGGTAjLWqHjTwM8QewN54I,27426
|
11
|
+
tinyagent/code_agent/utils.py,sha256=CAg8LKc_sxnXmwF0L2nRP13XsftYl3ZiD5qWO5D7QeQ,6842
|
11
12
|
tinyagent/code_agent/providers/__init__.py,sha256=myfy9qsBDjNOhcgXJ2E9jO1q5eo6jHp43I2k0k8esLY,136
|
12
13
|
tinyagent/code_agent/providers/base.py,sha256=Hm8jrD60QovgQHTwiFE1pKDHPk1cwGbUdzuSwic9Rjc,5832
|
13
|
-
tinyagent/code_agent/providers/modal_provider.py,sha256=
|
14
|
+
tinyagent/code_agent/providers/modal_provider.py,sha256=4uhmDA2pLYbbBi3actrBkHD_-pt3gt2incKBj12hab0,10084
|
14
15
|
tinyagent/code_agent/tools/__init__.py,sha256=0XtrgYBgBayOffW50KyrlmrXXs9iu6z1DHu7-D8WGqY,94
|
15
16
|
tinyagent/code_agent/tools/example_tools.py,sha256=YbXb7PKuvvxh-LV12Y4n_Ez3RyLA95gWOcZrKsa7UHg,1203
|
16
17
|
tinyagent/hooks/__init__.py,sha256=RZow2r0XHLJ3-tnmecScdc0_wrEdmOy5dtXqoiRME5Y,254
|
17
|
-
tinyagent/hooks/gradio_callback.py,sha256=
|
18
|
+
tinyagent/hooks/gradio_callback.py,sha256=3vKfGknn7XOHtcoF6DkLKDCJmC_oCRhu1-kqWmAAOc4,56821
|
18
19
|
tinyagent/hooks/logging_manager.py,sha256=UpdmpQ7HRPyer-jrmQSXcBwi409tV9LnGvXSHjTcYTI,7935
|
19
20
|
tinyagent/hooks/rich_code_ui_callback.py,sha256=PLcu5MOSoP4oZR3BtvcV9DquxcIT_d0WzSlkvaDcGOk,19820
|
20
21
|
tinyagent/hooks/rich_ui_callback.py,sha256=5iCNOiJmhc1lOL7ZjaOt5Sk3rompko4zu_pAxfTVgJQ,22897
|
@@ -25,8 +26,8 @@ tinyagent/storage/json_file_storage.py,sha256=SYD8lvTHu2-FEHm1tZmsrcgEOirBrlUsUM
|
|
25
26
|
tinyagent/storage/postgres_storage.py,sha256=IGwan8UXHNnTZFK1F8x4kvMDex3GAAGWUg9ePx_5IF4,9018
|
26
27
|
tinyagent/storage/redis_storage.py,sha256=hu3y7wHi49HkpiR-AW7cWVQuTVOUk1WaB8TEPGUKVJ8,1742
|
27
28
|
tinyagent/storage/sqlite_storage.py,sha256=ZyOYe0d_oHO1wOIT8FxKIbc67tP_0e_8FnM2Zq8Pwj8,5915
|
28
|
-
tinyagent_py-0.0.
|
29
|
-
tinyagent_py-0.0.
|
30
|
-
tinyagent_py-0.0.
|
31
|
-
tinyagent_py-0.0.
|
32
|
-
tinyagent_py-0.0.
|
29
|
+
tinyagent_py-0.0.12.dist-info/licenses/LICENSE,sha256=YIogcVQnknaaE4K-oaQylFWo8JGRBWnwmGb3fWB_Pww,1064
|
30
|
+
tinyagent_py-0.0.12.dist-info/METADATA,sha256=Q05U9Z6UJNPoHtmAbpMzxB2HfcnQbGOHHz3W1EfgWt0,13250
|
31
|
+
tinyagent_py-0.0.12.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
32
|
+
tinyagent_py-0.0.12.dist-info/top_level.txt,sha256=Ny8aJNchZpc2Vvhp3306L5vjceJakvFxBk-UjjVeA_I,10
|
33
|
+
tinyagent_py-0.0.12.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|