tinyagent-py 0.0.9__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/prompts/code_agent.yaml +329 -0
- tinyagent/tiny_agent.py +4 -7
- {tinyagent_py-0.0.9.dist-info → tinyagent_py-0.0.12.dist-info}/METADATA +1 -1
- {tinyagent_py-0.0.9.dist-info → tinyagent_py-0.0.12.dist-info}/RECORD +13 -11
- {tinyagent_py-0.0.9.dist-info → tinyagent_py-0.0.12.dist-info}/WHEEL +0 -0
- {tinyagent_py-0.0.9.dist-info → tinyagent_py-0.0.12.dist-info}/licenses/LICENSE +0 -0
- {tinyagent_py-0.0.9.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)
|