onetool-mcp 1.0.0b1__py3-none-any.whl → 1.0.0rc2__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.
Files changed (81) hide show
  1. onetool/cli.py +63 -4
  2. onetool_mcp-1.0.0rc2.dist-info/METADATA +266 -0
  3. onetool_mcp-1.0.0rc2.dist-info/RECORD +129 -0
  4. {onetool_mcp-1.0.0b1.dist-info → onetool_mcp-1.0.0rc2.dist-info}/licenses/LICENSE.txt +1 -1
  5. {onetool_mcp-1.0.0b1.dist-info → onetool_mcp-1.0.0rc2.dist-info}/licenses/NOTICE.txt +54 -64
  6. ot/__main__.py +6 -6
  7. ot/config/__init__.py +48 -46
  8. ot/config/global_templates/__init__.py +2 -2
  9. ot/config/{defaults → global_templates}/diagram-templates/api-flow.mmd +33 -33
  10. ot/config/{defaults → global_templates}/diagram-templates/c4-context.puml +30 -30
  11. ot/config/{defaults → global_templates}/diagram-templates/class-diagram.mmd +87 -87
  12. ot/config/{defaults → global_templates}/diagram-templates/feature-mindmap.mmd +70 -70
  13. ot/config/{defaults → global_templates}/diagram-templates/microservices.d2 +81 -81
  14. ot/config/{defaults → global_templates}/diagram-templates/project-gantt.mmd +37 -37
  15. ot/config/{defaults → global_templates}/diagram-templates/state-machine.mmd +42 -42
  16. ot/config/global_templates/diagram.yaml +167 -0
  17. ot/config/global_templates/onetool.yaml +3 -1
  18. ot/config/{defaults → global_templates}/prompts.yaml +102 -97
  19. ot/config/global_templates/security.yaml +31 -0
  20. ot/config/global_templates/servers.yaml +93 -12
  21. ot/config/global_templates/snippets.yaml +5 -26
  22. ot/config/{defaults → global_templates}/tool_templates/__init__.py +7 -7
  23. ot/config/loader.py +221 -105
  24. ot/config/mcp.py +5 -1
  25. ot/config/secrets.py +192 -190
  26. ot/decorators.py +116 -116
  27. ot/executor/__init__.py +35 -35
  28. ot/executor/base.py +16 -16
  29. ot/executor/fence_processor.py +83 -83
  30. ot/executor/linter.py +142 -142
  31. ot/executor/pep723.py +288 -288
  32. ot/executor/runner.py +20 -6
  33. ot/executor/simple.py +163 -163
  34. ot/executor/validator.py +603 -164
  35. ot/http_client.py +145 -145
  36. ot/logging/__init__.py +37 -37
  37. ot/logging/entry.py +213 -213
  38. ot/logging/format.py +191 -188
  39. ot/logging/span.py +349 -349
  40. ot/meta.py +236 -14
  41. ot/paths.py +32 -49
  42. ot/prompts.py +218 -218
  43. ot/proxy/manager.py +14 -2
  44. ot/registry/__init__.py +189 -189
  45. ot/registry/parser.py +269 -269
  46. ot/server.py +330 -315
  47. ot/shortcuts/__init__.py +15 -15
  48. ot/shortcuts/aliases.py +87 -87
  49. ot/shortcuts/snippets.py +258 -258
  50. ot/stats/__init__.py +35 -35
  51. ot/stats/html.py +2 -2
  52. ot/stats/reader.py +354 -354
  53. ot/stats/timing.py +57 -57
  54. ot/support.py +63 -63
  55. ot/tools.py +1 -1
  56. ot/utils/batch.py +161 -161
  57. ot/utils/cache.py +120 -120
  58. ot/utils/exceptions.py +23 -23
  59. ot/utils/factory.py +178 -179
  60. ot/utils/format.py +65 -65
  61. ot/utils/http.py +202 -202
  62. ot/utils/platform.py +45 -45
  63. ot/utils/truncate.py +69 -69
  64. ot_tools/__init__.py +4 -4
  65. ot_tools/_convert/__init__.py +12 -12
  66. ot_tools/_convert/pdf.py +254 -254
  67. ot_tools/diagram.yaml +167 -167
  68. ot_tools/scaffold.py +2 -2
  69. ot_tools/transform.py +124 -19
  70. ot_tools/web_fetch.py +94 -43
  71. onetool_mcp-1.0.0b1.dist-info/METADATA +0 -163
  72. onetool_mcp-1.0.0b1.dist-info/RECORD +0 -132
  73. ot/config/defaults/bench.yaml +0 -4
  74. ot/config/defaults/onetool.yaml +0 -25
  75. ot/config/defaults/servers.yaml +0 -7
  76. ot/config/defaults/snippets.yaml +0 -4
  77. ot_tools/firecrawl.py +0 -732
  78. {onetool_mcp-1.0.0b1.dist-info → onetool_mcp-1.0.0rc2.dist-info}/WHEEL +0 -0
  79. {onetool_mcp-1.0.0b1.dist-info → onetool_mcp-1.0.0rc2.dist-info}/entry_points.txt +0 -0
  80. /ot/config/{defaults → global_templates}/tool_templates/extension.py +0 -0
  81. /ot/config/{defaults → global_templates}/tool_templates/isolated.py +0 -0
ot/executor/validator.py CHANGED
@@ -2,23 +2,28 @@
2
2
 
3
3
  Validates Python code before execution:
4
4
  - Syntax validation via ast.parse()
5
- - Security pattern detection (dangerous calls)
5
+ - Allowlist-based security validation (dangerous calls, imports, builtins)
6
6
  - Optional Ruff linting integration for style warnings
7
7
 
8
- Security patterns are configurable via onetool.yaml and support wildcards:
8
+ Security Model:
9
+ Block everything by default, explicitly allow what's safe.
10
+ Configuration via security.yaml with three categories:
11
+ - builtins: Allowed builtin functions and types
12
+ - imports: Allowed modules for import statements
13
+ - calls: Blocked/warned qualified function calls
9
14
 
15
+ Tool namespaces (ot.*, brave.*, etc.) are auto-allowed.
16
+
17
+ Configuration:
10
18
  security:
11
- validate_code: true
12
- enabled: true
13
- blocked: [exec, eval, compile, __import__, subprocess.*, os.system]
14
- warned: [subprocess, os, open, pickle.*, yaml.load]
15
-
16
- Pattern matching logic (automatic based on pattern structure):
17
- - Patterns WITHOUT dots (e.g., 'exec', 'subprocess') match:
18
- * Builtin function calls: exec(), eval()
19
- * Import statements: import subprocess, from os import system
20
- - Patterns WITH dots (e.g., 'subprocess.*', 'os.system') match:
21
- * Qualified function calls: subprocess.run(), os.system()
19
+ builtins:
20
+ allow: [str, int, list, print, ...]
21
+ imports:
22
+ allow: [json, re, math, ...]
23
+ warn: [yaml]
24
+ calls:
25
+ block: [pickle.*, yaml.load]
26
+ warn: [random.seed]
22
27
 
23
28
  Wildcard patterns use fnmatch syntax:
24
29
  - '*' matches any characters (e.g., 'subprocess.*' matches 'subprocess.run')
@@ -37,8 +42,10 @@ from __future__ import annotations
37
42
 
38
43
  import ast
39
44
  import fnmatch
45
+ import sys
40
46
  from dataclasses import dataclass, field
41
- from typing import TYPE_CHECKING
47
+ from functools import lru_cache
48
+ from typing import TYPE_CHECKING, Any
42
49
 
43
50
  if TYPE_CHECKING:
44
51
  from ot.config.loader import SecurityConfig
@@ -55,47 +62,43 @@ class ValidationResult:
55
62
 
56
63
 
57
64
  # =============================================================================
58
- # Default Security Patterns
59
- # =============================================================================
60
- # These are used when config is not available. Patterns use fnmatch wildcards.
61
- #
62
- # Pattern matching is determined by structure:
63
- # - No dot in pattern → matches builtins (calls) AND imports
64
- # - Dot in pattern → matches qualified function calls only
65
- #
66
- # This two-category design (blocked/warned) replaces the previous four-category
67
- # design (blocked_builtins, blocked_functions, warned_functions, warned_imports).
68
- # The validator automatically determines the match type based on pattern structure.
65
+ # Fallback Defaults (used when config is unavailable)
69
66
  # =============================================================================
70
-
71
- DEFAULT_BLOCKED = frozenset(
72
- {
73
- # Builtins - arbitrary code execution risk (no dots = match calls + imports)
74
- "exec",
75
- "eval",
76
- "compile",
77
- "__import__",
78
- # Qualified functions - command injection (dots = match calls only)
79
- "subprocess.*", # All subprocess functions
80
- "os.system", # Shell command execution
81
- "os.popen", # Shell command execution
82
- "os.spawn*", # spawnl, spawnle, spawnv, etc.
83
- "os.exec*", # execl, execle, execv, etc.
84
- }
85
- )
86
-
87
- DEFAULT_WARNED = frozenset(
88
- {
89
- # Module imports - enables dangerous operations (no dots = match imports)
90
- "subprocess",
91
- "os",
92
- # Qualified functions - file access, deserialization (dots = match calls)
93
- "open", # File access (common but risky)
94
- "pickle.*", # Deserialization attacks
95
- "yaml.load", # Unsafe YAML deserialization
96
- "marshal.*", # Object deserialization
97
- }
98
- )
67
+ # These are minimal safe defaults used only when security.yaml cannot be loaded.
68
+ # In normal operation, the full allowlists come from security.yaml.
69
+
70
+ FALLBACK_ALLOWED_BUILTINS = frozenset({
71
+ # Type constructors
72
+ "str", "int", "float", "bool", "bytes", "list", "dict", "set", "tuple",
73
+ # Type checking
74
+ "isinstance", "issubclass", "type", "callable",
75
+ # Iteration
76
+ "len", "iter", "next", "range", "enumerate", "zip", "reversed", "sorted",
77
+ # Math
78
+ "min", "max", "sum", "abs", "round", "pow",
79
+ # Sequence operations
80
+ "all", "any", "filter", "map",
81
+ # String/repr
82
+ "repr", "format", "print",
83
+ # Exceptions
84
+ "Exception", "ValueError", "TypeError", "KeyError",
85
+ })
86
+
87
+ FALLBACK_ALLOWED_IMPORTS = frozenset({
88
+ "json", "re", "math", "datetime", "collections", "typing",
89
+ "itertools", "functools", "copy", "dataclasses",
90
+ })
91
+
92
+ FALLBACK_WARNED_IMPORTS = frozenset({
93
+ "yaml",
94
+ })
95
+
96
+ # Cache stdlib module names for performance (checked per qualified call)
97
+ # Python 3.10+ has sys.stdlib_module_names; older versions get empty set
98
+ try:
99
+ STDLIB_MODULE_NAMES: frozenset[str] = frozenset(sys.stdlib_module_names)
100
+ except AttributeError:
101
+ STDLIB_MODULE_NAMES = frozenset()
99
102
 
100
103
 
101
104
  def _matches_pattern(name: str, patterns: frozenset[str]) -> str | None:
@@ -124,19 +127,12 @@ def _matches_pattern(name: str, patterns: frozenset[str]) -> str | None:
124
127
  return None
125
128
 
126
129
 
127
- def _has_dot(pattern: str) -> bool:
128
- """Check if a pattern contains a dot (qualified name).
129
-
130
- Used to determine matching strategy:
131
- - No dot: match builtins and imports
132
- - Has dot: match qualified function calls
133
- """
134
- return "." in pattern
135
-
136
-
130
+ @lru_cache(maxsize=1)
137
131
  def _get_security_config() -> SecurityConfig | None:
138
132
  """Get security configuration from global config.
139
133
 
134
+ Results are cached for performance. Cache is cleared on ot.reload().
135
+
140
136
  Returns:
141
137
  SecurityConfig if available, None otherwise.
142
138
  """
@@ -146,125 +142,392 @@ def _get_security_config() -> SecurityConfig | None:
146
142
  config = get_config()
147
143
  return config.security
148
144
  except Exception:
149
- # Config not loaded yet or error - use defaults
145
+ # Config not loaded yet or error - use fallback defaults
150
146
  return None
151
147
 
152
148
 
153
- class DangerousPatternVisitor(ast.NodeVisitor):
154
- """AST visitor that detects dangerous code patterns.
149
+ @lru_cache(maxsize=1)
150
+ def _get_tool_namespaces() -> frozenset[str]:
151
+ """Get all tool pack namespaces for auto-allow.
155
152
 
156
- Patterns are configurable via onetool.yaml security section.
157
- Supports fnmatch wildcards (*, ?, [seq]).
153
+ Tool namespaces (ot.*, brave.*, file.*, etc.) are auto-allowed
154
+ since they're the whole point of OneTool.
158
155
 
159
- Pattern matching is automatic based on structure:
160
- - Patterns without dots match builtins and imports
161
- - Patterns with dots match qualified function calls
156
+ Results are cached for performance (registry doesn't change during session).
162
157
 
163
- Two-tier priority: allow > warned > blocked
158
+ Returns:
159
+ Frozenset of namespace patterns like "ot.*", "brave.*"
160
+ """
161
+ try:
162
+ from ot.executor.tool_loader import load_tool_registry
163
+ from ot.proxy import get_proxy_manager
164
+
165
+ registry = load_tool_registry()
166
+ proxy = get_proxy_manager()
167
+
168
+ # Collect all pack names
169
+ namespaces = set(registry.packs.keys())
170
+ namespaces.update(proxy.servers)
171
+
172
+ # Convert to wildcard patterns
173
+ return frozenset(f"{ns}.*" for ns in namespaces)
174
+ except Exception:
175
+ # Registry not loaded - return minimal set
176
+ return frozenset({"ot.*"})
177
+
178
+
179
+ class AllowlistValidator(ast.NodeVisitor):
180
+ """AST visitor that validates code against allowlists.
181
+
182
+ Security model: Block everything by default, allow what's explicitly listed.
183
+ Tool namespaces are auto-allowed.
184
+
185
+ Categories:
186
+ - builtins: Allowed builtin function calls
187
+ - imports: Allowed module imports
188
+ - calls: Blocked/warned qualified function calls
189
+
190
+ Tracks import aliases and from-imports to prevent bypass attacks:
191
+ - `import subprocess as sp; sp.run()` - alias tracked
192
+ - `from subprocess import run; run()` - function tracked
164
193
  """
165
194
 
166
195
  def __init__(
167
196
  self,
168
- blocked: frozenset[str] | None = None,
169
- warned: frozenset[str] | None = None,
197
+ allowed_builtins: frozenset[str],
198
+ allowed_imports: frozenset[str],
199
+ warned_imports: frozenset[str],
200
+ blocked_calls: frozenset[str],
201
+ warned_calls: frozenset[str],
202
+ allowed_calls: frozenset[str],
203
+ tool_namespaces: frozenset[str],
204
+ allowed_dunders: frozenset[str],
170
205
  ) -> None:
171
- """Initialize visitor with security patterns.
206
+ """Initialize validator with allowlists.
172
207
 
173
208
  Args:
174
- blocked: Patterns that block execution (cause errors)
175
- warned: Patterns that generate warnings (allow execution)
209
+ allowed_builtins: Allowed builtin functions and types
210
+ allowed_imports: Allowed module imports
211
+ warned_imports: Imports that trigger warnings but are allowed
212
+ blocked_calls: Blocked qualified function calls
213
+ warned_calls: Qualified calls that trigger warnings
214
+ allowed_calls: Explicitly allowed qualified calls
215
+ tool_namespaces: Auto-allowed tool namespace patterns (e.g., "ot.*")
216
+ allowed_dunders: Allowed magic variables (e.g., "__format__")
176
217
  """
177
218
  self.errors: list[str] = []
178
219
  self.warnings: list[str] = []
179
220
 
180
- # Use provided patterns or defaults
181
- blocked_all = blocked or DEFAULT_BLOCKED
182
- warned_all = warned or DEFAULT_WARNED
221
+ self.allowed_builtins = allowed_builtins
222
+ self.allowed_imports = allowed_imports
223
+ self.warned_imports = warned_imports
224
+ self.blocked_calls = blocked_calls
225
+ self.warned_calls = warned_calls
226
+ self.allowed_calls = allowed_calls
227
+ self.tool_namespaces = tool_namespaces
228
+ self.allowed_dunders = allowed_dunders
229
+
230
+ # Track import aliases: alias_name -> original_module
231
+ # e.g., "import subprocess as sp" -> {"sp": "subprocess"}
232
+ self._import_aliases: dict[str, str] = {}
233
+
234
+ # Track from-imports from blocked modules: function_name -> original_module
235
+ # e.g., "from subprocess import run" -> {"run": "subprocess"}
236
+ self._from_imports: dict[str, str] = {}
183
237
 
184
- # Split patterns by type for efficient matching
185
- # Patterns without dots match builtins and imports
186
- # Patterns with dots match qualified calls
187
- self.blocked_simple = frozenset(p for p in blocked_all if not _has_dot(p))
188
- self.blocked_qualified = frozenset(p for p in blocked_all if _has_dot(p))
189
- self.warned_simple = frozenset(p for p in warned_all if not _has_dot(p))
190
- self.warned_qualified = frozenset(p for p in warned_all if _has_dot(p))
238
+ def _is_tool_namespace(self, name: str) -> bool:
239
+ """Check if a qualified name is in an auto-allowed tool namespace."""
240
+ return _matches_pattern(name, self.tool_namespaces) is not None
241
+
242
+ def _is_allowed_dunder(self, name: str) -> bool:
243
+ """Check if a name is an allowed magic variable."""
244
+ return name in self.allowed_dunders
191
245
 
192
246
  def visit_Call(self, node: ast.Call) -> None:
193
- """Check function calls for dangerous patterns.
247
+ """Check function calls against allowlists."""
248
+ # First check for __builtins__ bypass via getattr/hasattr
249
+ if self._is_builtins_bypass_call(node):
250
+ self.errors.append(
251
+ f"Line {node.lineno}: Access to '__builtins__' via getattr/hasattr is not allowed "
252
+ f"(potential security bypass)"
253
+ )
254
+ self.generic_visit(node)
255
+ return
194
256
 
195
- Priority order: blocked > warned (allow handled at setup)
196
- """
197
257
  func_name = self._get_call_name(node)
198
258
 
199
259
  if not func_name:
200
260
  self.generic_visit(node)
201
261
  return
202
262
 
203
- # Determine which pattern sets to check based on call type
204
263
  is_qualified = "." in func_name
205
264
 
206
265
  if is_qualified:
207
- # Qualified call (e.g., subprocess.run) - check qualified patterns
208
- # Priority: blocked > warned
209
- if pattern := _matches_pattern(func_name, self.blocked_qualified):
266
+ # Qualified call (e.g., json.loads, subprocess.run)
267
+ self._check_qualified_call(func_name, node.lineno)
268
+ else:
269
+ # Simple call (builtin like print, len, etc.)
270
+ self._check_builtin_call(func_name, node.lineno)
271
+
272
+ self.generic_visit(node)
273
+
274
+ def _is_builtins_bypass_call(self, node: ast.Call) -> bool:
275
+ """Check if this is an attempt to bypass via getattr(__builtins__, ...).
276
+
277
+ Detects patterns like:
278
+ - getattr(__builtins__, 'exec')
279
+ - hasattr(__builtins__, 'exec')
280
+ """
281
+ func_name = self._get_call_name(node)
282
+ if func_name not in ("getattr", "hasattr"):
283
+ return False
284
+
285
+ # Check if first argument is __builtins__
286
+ return (
287
+ bool(node.args)
288
+ and isinstance(node.args[0], ast.Name)
289
+ and node.args[0].id == "__builtins__"
290
+ )
291
+
292
+ def _check_builtin_call(self, func_name: str, lineno: int) -> None:
293
+ """Check if a builtin call is allowed.
294
+
295
+ Also checks for from-import bypass:
296
+ `from subprocess import run; run()` - run is tracked as from subprocess
297
+
298
+ Only checks known dangerous builtins. User-defined functions
299
+ (like functions defined in the same code) are allowed.
300
+ """
301
+ # Check for from-import bypass first
302
+ # e.g., "from subprocess import run; run()" -> func_name is "run"
303
+ if func_name in self._from_imports:
304
+ source_module = self._from_imports[func_name]
305
+ # Check if the source module is allowed
306
+ if _matches_pattern(source_module, self.allowed_imports) is None:
210
307
  self.errors.append(
211
- f"Line {node.lineno}: Dangerous function '{func_name}' is not "
212
- f"allowed (matches '{pattern}')"
308
+ f"Line {lineno}: Call to '{func_name}' is not allowed "
309
+ f"(imported from blocked module '{source_module}'). "
310
+ f"Use ot.security(check='{source_module}') to check security rules."
213
311
  )
214
- elif pattern := _matches_pattern(func_name, self.warned_qualified):
215
- self.warnings.append(
216
- f"Line {node.lineno}: Potentially unsafe function '{func_name}' "
217
- f"(matches '{pattern}')"
312
+ return
313
+ # Module is allowed, check if specific call is blocked
314
+ qualified_name = f"{source_module}.{func_name}"
315
+ if pattern := _matches_pattern(qualified_name, self.blocked_calls):
316
+ self.errors.append(
317
+ f"Line {lineno}: Call to '{func_name}' is blocked "
318
+ f"(matches '{pattern}' via 'from {source_module} import'). "
319
+ f"Use ot.security(check='{qualified_name}') to check security rules."
218
320
  )
321
+ return
322
+ # Allowed module, not blocked call - allow
323
+ return
324
+
325
+ # Allowed dunders pass through
326
+ if self._is_allowed_dunder(func_name):
327
+ return
328
+
329
+ # Check against allowed builtins
330
+ if _matches_pattern(func_name, self.allowed_builtins) is not None:
331
+ return
332
+
333
+ # Check if this is a known dangerous builtin that we explicitly block
334
+ # User-defined functions (not in Python's builtins) are allowed
335
+ try:
336
+ import builtins
337
+ if not hasattr(builtins, func_name):
338
+ # Not a builtin - allow user-defined functions
339
+ return
340
+ except (ImportError, AttributeError):
341
+ pass
342
+
343
+ # Not allowed - block
344
+ self.errors.append(
345
+ f"Line {lineno}: Builtin '{func_name}' is not allowed. "
346
+ f"Use ot.security(check='{func_name}') to check security rules."
347
+ )
348
+
349
+ def _check_qualified_call(self, func_name: str, lineno: int) -> None:
350
+ """Check if a qualified call is allowed.
351
+
352
+ Also resolves import aliases:
353
+ `import subprocess as sp; sp.run()` -> resolves sp to subprocess
354
+
355
+ Only checks known dangerous patterns. Method calls on variables
356
+ (like results.append()) are allowed by default since we can't
357
+ statically determine if 'results' is a dangerous module.
358
+ """
359
+ # Resolve import aliases first
360
+ # e.g., "import subprocess as sp; sp.run()" -> func_name is "sp.run"
361
+ # We need to resolve "sp" to "subprocess"
362
+ parts = func_name.split(".")
363
+ module_part = parts[0]
364
+ original_module = self._import_aliases.get(module_part, module_part)
365
+
366
+ # If alias was resolved, reconstruct the full name
367
+ if original_module != module_part:
368
+ resolved_name = ".".join([original_module, *parts[1:]])
219
369
  else:
220
- # Simple call (e.g., exec) - check simple patterns (builtins)
221
- # Priority: blocked > warned
222
- if pattern := _matches_pattern(func_name, self.blocked_simple):
370
+ resolved_name = func_name
371
+
372
+ # Tool namespaces are auto-allowed
373
+ if self._is_tool_namespace(resolved_name):
374
+ return
375
+
376
+ # Explicitly allowed calls pass through
377
+ if _matches_pattern(resolved_name, self.allowed_calls) is not None:
378
+ return
379
+
380
+ # Check blocked patterns first - these are explicitly dangerous
381
+ if pattern := _matches_pattern(resolved_name, self.blocked_calls):
382
+ if original_module != module_part:
383
+ # Alias was used - include both in error message
223
384
  self.errors.append(
224
- f"Line {node.lineno}: Dangerous builtin '{func_name}' is not "
225
- f"allowed (matches '{pattern}')"
385
+ f"Line {lineno}: Call '{func_name}' is blocked "
386
+ f"('{module_part}' is alias for '{original_module}', matches '{pattern}'). "
387
+ f"Use ot.security(check='{resolved_name}') to check security rules."
226
388
  )
227
- elif pattern := _matches_pattern(func_name, self.warned_simple):
228
- self.warnings.append(
229
- f"Line {node.lineno}: Potentially unsafe function '{func_name}' "
230
- f"(matches '{pattern}')"
389
+ else:
390
+ self.errors.append(
391
+ f"Line {lineno}: Call '{func_name}' is blocked (matches '{pattern}'). "
392
+ f"Use ot.security(check='{func_name}') to check security rules."
231
393
  )
394
+ return
232
395
 
233
- # Continue visiting child nodes
234
- self.generic_visit(node)
396
+ # Check warned patterns
397
+ if pattern := _matches_pattern(resolved_name, self.warned_calls):
398
+ self.warnings.append(
399
+ f"Line {lineno}: Call '{resolved_name}' may be unsafe (matches '{pattern}')"
400
+ )
401
+ return
235
402
 
236
- def visit_Import(self, node: ast.Import) -> None:
237
- """Check imports for dangerous modules."""
238
- for alias in node.names:
239
- # Check simple patterns (no dots) against import names
240
- if pattern := _matches_pattern(alias.name, self.blocked_simple):
403
+ # Check if the module part is in allowed imports
404
+ # e.g., for json.loads, check if "json" is allowed
405
+ if _matches_pattern(original_module, self.allowed_imports) is not None:
406
+ # Module is allowed, so its functions are implicitly allowed
407
+ return
408
+
409
+ # For method calls on variables (not known modules), allow by default
410
+ # We can't statically determine if 'results.append()' is dangerous
411
+ # vs 'os.system()' without runtime type information.
412
+ # Only block if explicitly in blocked_calls or if module_part is
413
+ # a known dangerous import that's not allowed.
414
+ #
415
+ # Check if module_part looks like a known stdlib module that
416
+ # should be blocked (e.g., os, subprocess, pickle)
417
+ # Uses cached STDLIB_MODULE_NAMES for performance
418
+ if STDLIB_MODULE_NAMES and original_module in STDLIB_MODULE_NAMES:
419
+ if original_module != module_part:
241
420
  self.errors.append(
242
- f"Line {node.lineno}: Import of '{alias.name}' is not allowed "
243
- f"(matches '{pattern}')"
421
+ f"Line {lineno}: Module '{original_module}' "
422
+ f"(aliased as '{module_part}') is not in allowed imports. "
423
+ f"Use ot.security(check='{original_module}') to check security rules."
244
424
  )
245
- elif pattern := _matches_pattern(alias.name, self.warned_simple):
246
- self.warnings.append(
247
- f"Line {node.lineno}: Import of '{alias.name}' may enable "
248
- f"dangerous operations (matches '{pattern}')"
425
+ else:
426
+ self.errors.append(
427
+ f"Line {lineno}: Module '{original_module}' is not in allowed imports. "
428
+ f"Use ot.security(check='{original_module}') to check security rules."
249
429
  )
430
+ return
431
+
432
+ # Not a known stdlib module - allow method calls on variables
433
+
434
+ def visit_Import(self, node: ast.Import) -> None:
435
+ """Check import statements against allowlists.
436
+
437
+ Also tracks aliases for bypass prevention:
438
+ `import subprocess as sp` -> tracks sp -> subprocess
439
+ """
440
+ for alias in node.names:
441
+ module_name = alias.name
442
+ self._check_import(module_name, node.lineno)
443
+
444
+ # Track alias if present (e.g., "import subprocess as sp")
445
+ # This prevents bypass via: import blocked as alias; alias.func()
446
+ if alias.asname:
447
+ self._import_aliases[alias.asname] = module_name
448
+ else:
449
+ # Even without alias, track the module name for consistency
450
+ # e.g., "import subprocess" -> subprocess.run() uses "subprocess"
451
+ self._import_aliases[module_name] = module_name
452
+
250
453
  self.generic_visit(node)
251
454
 
252
455
  def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
253
- """Check from imports for dangerous modules."""
456
+ """Check from imports against allowlists.
457
+
458
+ Also tracks imported names for bypass prevention:
459
+ `from subprocess import run` -> tracks run -> subprocess
460
+ `from subprocess import run as r` -> tracks r -> subprocess
461
+ """
254
462
  if node.module:
255
- # Check simple patterns against the module name
256
- if pattern := _matches_pattern(node.module, self.blocked_simple):
463
+ self._check_import(node.module, node.lineno)
464
+
465
+ # Track all imported names from this module
466
+ # This prevents bypass via: from blocked import func; func()
467
+ for alias in node.names:
468
+ if alias.name == "*":
469
+ # Star imports are dangerous - we can't track what's imported
470
+ # The module check above will already block if not allowed
471
+ self.warnings.append(
472
+ f"Line {node.lineno}: Star import from '{node.module}' - "
473
+ f"cannot track imported names for security validation"
474
+ )
475
+ continue
476
+
477
+ # Use the alias name if present, otherwise the original name
478
+ local_name = alias.asname if alias.asname else alias.name
479
+ # Map local name -> (module, original_name)
480
+ self._from_imports[local_name] = node.module
481
+
482
+ self.generic_visit(node)
483
+
484
+ def _check_import(self, module_name: str, lineno: int) -> None:
485
+ """Check if a module import is allowed."""
486
+ # Check allowed imports
487
+ if _matches_pattern(module_name, self.allowed_imports) is not None:
488
+ return
489
+
490
+ # Check warned imports
491
+ if pattern := _matches_pattern(module_name, self.warned_imports):
492
+ self.warnings.append(
493
+ f"Line {lineno}: Import of '{module_name}' may enable "
494
+ f"unsafe operations (matches '{pattern}')"
495
+ )
496
+ return
497
+
498
+ # Not allowed - block
499
+ self.errors.append(
500
+ f"Line {lineno}: Import of '{module_name}' is not allowed. "
501
+ f"Use ot.security(check='{module_name}') to check security rules."
502
+ )
503
+
504
+ def visit_Assign(self, node: ast.Assign) -> None:
505
+ """Check assignments to magic variables."""
506
+ for target in node.targets:
507
+ if (
508
+ isinstance(target, ast.Name)
509
+ and target.id.startswith("__")
510
+ and target.id.endswith("__")
511
+ and not self._is_allowed_dunder(target.id)
512
+ ):
257
513
  self.errors.append(
258
- f"Line {node.lineno}: Import from '{node.module}' is not allowed "
259
- f"(matches '{pattern}')"
260
- )
261
- elif pattern := _matches_pattern(node.module, self.warned_simple):
262
- self.warnings.append(
263
- f"Line {node.lineno}: Import from '{node.module}' may enable "
264
- f"dangerous operations (matches '{pattern}')"
514
+ f"Line {node.lineno}: Assignment to '{target.id}' is not allowed"
265
515
  )
266
516
  self.generic_visit(node)
267
517
 
518
+ def visit_Subscript(self, node: ast.Subscript) -> None:
519
+ """Block subscript access to __builtins__.
520
+
521
+ Prevents bypass via: __builtins__['exec']('code')
522
+ """
523
+ # Check if subscripting __builtins__
524
+ if isinstance(node.value, ast.Name) and node.value.id == "__builtins__":
525
+ self.errors.append(
526
+ f"Line {node.lineno}: Access to '__builtins__' is not allowed "
527
+ f"(potential security bypass)"
528
+ )
529
+ self.generic_visit(node)
530
+
268
531
  def _get_call_name(self, node: ast.Call) -> str:
269
532
  """Extract the full name of a function call.
270
533
 
@@ -295,9 +558,8 @@ def validate_python_code(
295
558
  ) -> ValidationResult:
296
559
  """Validate Python code for syntax and security issues.
297
560
 
298
- Security patterns are loaded from onetool.yaml configuration.
299
- If config is not available, built-in defaults are used.
300
- Patterns support fnmatch wildcards (*, ?, [seq]).
561
+ Security validation uses allowlist-based rules from security.yaml.
562
+ If config is not available, minimal fallback defaults are used.
301
563
 
302
564
  Args:
303
565
  code: Python code to validate
@@ -320,40 +582,37 @@ def validate_python_code(
320
582
  result.errors.append(f"Syntax error{line_info}: {e.msg}")
321
583
  return result
322
584
 
323
- # Step 2: Security pattern detection
585
+ # Step 2: Security validation
324
586
  if check_security:
325
- # Load patterns from config or use defaults
326
587
  security_config = _get_security_config()
327
588
 
328
- if security_config is not None and security_config.enabled:
329
- # Merge config patterns with defaults (additive behavior)
330
- # This prevents accidental removal of critical security patterns
331
- allow_set = frozenset(security_config.allow)
332
- warned_set = frozenset(security_config.warned)
333
-
334
- # Pattern priority (highest to lowest):
335
- # 1. allow - completely exempt, no action
336
- # 2. warned (user) - downgrades blocked defaults to warnings
337
- # 3. blocked - prevents execution
338
- # 4. warned (default) - generates warnings
339
- #
340
- # This lets users:
341
- # - Add to blocked/warned (extends defaults)
342
- # - Downgrade blocked→warned (e.g., warned: [os.popen])
343
- # - Exempt entirely (e.g., allow: [open])
344
- blocked = (DEFAULT_BLOCKED | set(security_config.blocked)) - allow_set - warned_set
345
- warned = (DEFAULT_WARNED | set(security_config.warned)) - allow_set
346
-
347
- visitor = DangerousPatternVisitor(
348
- blocked=frozenset(blocked),
349
- warned=frozenset(warned),
350
- )
351
- elif security_config is not None and not security_config.enabled:
589
+ if security_config is not None and not security_config.enabled:
352
590
  # Security disabled in config - skip validation
353
591
  visitor = None
592
+ elif security_config is not None:
593
+ # Use config-driven allowlists
594
+ visitor = AllowlistValidator(
595
+ allowed_builtins=security_config.get_allowed_builtins(),
596
+ allowed_imports=security_config.get_allowed_imports(),
597
+ warned_imports=security_config.get_warned_imports(),
598
+ blocked_calls=security_config.get_blocked_calls(),
599
+ warned_calls=security_config.get_warned_calls(),
600
+ allowed_calls=security_config.get_allowed_calls(),
601
+ tool_namespaces=_get_tool_namespaces(),
602
+ allowed_dunders=security_config.get_allowed_dunders(),
603
+ )
354
604
  else:
355
- # No config - use defaults
356
- visitor = DangerousPatternVisitor()
605
+ # No config - use fallback defaults
606
+ visitor = AllowlistValidator(
607
+ allowed_builtins=FALLBACK_ALLOWED_BUILTINS,
608
+ allowed_imports=FALLBACK_ALLOWED_IMPORTS,
609
+ warned_imports=FALLBACK_WARNED_IMPORTS,
610
+ blocked_calls=frozenset(),
611
+ warned_calls=frozenset(),
612
+ allowed_calls=frozenset(),
613
+ tool_namespaces=_get_tool_namespaces(),
614
+ allowed_dunders=frozenset({"__format__", "__sanitize__"}),
615
+ )
357
616
 
358
617
  if visitor is not None:
359
618
  visitor.visit(tree)
@@ -396,3 +655,183 @@ def validate_for_exec(code: str) -> ValidationResult:
396
655
  # For example, checking for top-level returns outside functions
397
656
 
398
657
  return result
658
+
659
+
660
+ # =============================================================================
661
+ # Security Introspection (used by ot.security())
662
+ # =============================================================================
663
+
664
+
665
+ def get_security_status(pattern: str) -> dict[str, str | bool]:
666
+ """Check the security status of a specific pattern.
667
+
668
+ Used by ot.security(check=pattern) for agent introspection.
669
+
670
+ Args:
671
+ pattern: Pattern to check (e.g., "os", "json.loads", "pickle.*")
672
+
673
+ Returns:
674
+ Dict with 'pattern', 'status' (allowed/blocked/warned), 'category',
675
+ and 'reason' explaining why.
676
+ """
677
+ security_config = _get_security_config()
678
+ tool_namespaces = _get_tool_namespaces()
679
+
680
+ is_qualified = "." in pattern
681
+
682
+ if is_qualified:
683
+ # Check if it's a tool namespace
684
+ if _matches_pattern(pattern, tool_namespaces):
685
+ namespace = pattern.split(".")[0]
686
+ return {
687
+ "pattern": pattern,
688
+ "status": "allowed",
689
+ "category": "tool_namespace",
690
+ "reason": f"Tool namespace '{namespace}' is auto-allowed",
691
+ }
692
+
693
+ if security_config:
694
+ # Check explicitly allowed calls
695
+ if _matches_pattern(pattern, security_config.get_allowed_calls()):
696
+ return {
697
+ "pattern": pattern,
698
+ "status": "allowed",
699
+ "category": "calls",
700
+ "reason": "Explicitly allowed in security.yaml calls.allow",
701
+ }
702
+
703
+ # Check blocked calls
704
+ if match := _matches_pattern(pattern, security_config.get_blocked_calls()):
705
+ return {
706
+ "pattern": pattern,
707
+ "status": "blocked",
708
+ "category": "calls",
709
+ "reason": f"Blocked by pattern '{match}' in security.yaml calls.block",
710
+ }
711
+
712
+ # Check warned calls
713
+ if match := _matches_pattern(pattern, security_config.get_warned_calls()):
714
+ return {
715
+ "pattern": pattern,
716
+ "status": "warned",
717
+ "category": "calls",
718
+ "reason": f"Warned by pattern '{match}' in security.yaml calls.warn",
719
+ }
720
+
721
+ # Check if module part is allowed
722
+ module_part = pattern.split(".")[0]
723
+ if _matches_pattern(module_part, security_config.get_allowed_imports()):
724
+ return {
725
+ "pattern": pattern,
726
+ "status": "allowed",
727
+ "category": "imports",
728
+ "reason": f"Module '{module_part}' is in allowed imports",
729
+ }
730
+
731
+ # Default: blocked
732
+ return {
733
+ "pattern": pattern,
734
+ "status": "blocked",
735
+ "category": "calls",
736
+ "reason": "Not in any allowlist (default: block)",
737
+ }
738
+
739
+ else:
740
+ # Unqualified name - check builtins and imports
741
+ if security_config:
742
+ # Check allowed builtins
743
+ if _matches_pattern(pattern, security_config.get_allowed_builtins()):
744
+ return {
745
+ "pattern": pattern,
746
+ "status": "allowed",
747
+ "category": "builtins",
748
+ "reason": "In security.yaml builtins.allow",
749
+ }
750
+
751
+ # Check allowed imports
752
+ if _matches_pattern(pattern, security_config.get_allowed_imports()):
753
+ return {
754
+ "pattern": pattern,
755
+ "status": "allowed",
756
+ "category": "imports",
757
+ "reason": "In security.yaml imports.allow",
758
+ }
759
+
760
+ # Check warned imports
761
+ if match := _matches_pattern(pattern, security_config.get_warned_imports()):
762
+ return {
763
+ "pattern": pattern,
764
+ "status": "warned",
765
+ "category": "imports",
766
+ "reason": f"Warned by pattern '{match}' in security.yaml imports.warn",
767
+ }
768
+
769
+ # Check allowed dunders
770
+ if pattern in security_config.get_allowed_dunders():
771
+ return {
772
+ "pattern": pattern,
773
+ "status": "allowed",
774
+ "category": "dunders",
775
+ "reason": "In security.yaml dunders.allow",
776
+ }
777
+
778
+ # Default: blocked
779
+ return {
780
+ "pattern": pattern,
781
+ "status": "blocked",
782
+ "category": "builtins",
783
+ "reason": "Not in any allowlist (default: block)",
784
+ }
785
+
786
+
787
+ def get_security_summary() -> dict[str, Any]:
788
+ """Get a summary of the current security configuration.
789
+
790
+ Used by ot.security() for agent introspection.
791
+
792
+ Returns:
793
+ Dict with counts and sample items for each category.
794
+ """
795
+ security_config = _get_security_config()
796
+ tool_namespaces = _get_tool_namespaces()
797
+
798
+ if security_config is None:
799
+ return {
800
+ "status": "fallback",
801
+ "message": "Using fallback defaults (security.yaml not loaded)",
802
+ "builtins_allowed": len(FALLBACK_ALLOWED_BUILTINS),
803
+ "imports_allowed": len(FALLBACK_ALLOWED_IMPORTS),
804
+ "tool_namespaces": sorted(tool_namespaces),
805
+ }
806
+
807
+ builtins = security_config.get_allowed_builtins()
808
+ imports = security_config.get_allowed_imports()
809
+ warned_imports = security_config.get_warned_imports()
810
+ blocked_calls = security_config.get_blocked_calls()
811
+ warned_calls = security_config.get_warned_calls()
812
+ dunders = security_config.get_allowed_dunders()
813
+
814
+ return {
815
+ "status": "configured",
816
+ "enabled": security_config.enabled,
817
+ "builtins": {
818
+ "allowed_count": len(builtins),
819
+ "sample": sorted(builtins)[:10],
820
+ },
821
+ "imports": {
822
+ "allowed_count": len(imports),
823
+ "warned_count": len(warned_imports),
824
+ "sample_allowed": sorted(imports)[:10],
825
+ "warned": sorted(warned_imports),
826
+ },
827
+ "calls": {
828
+ "blocked_count": len(blocked_calls),
829
+ "warned_count": len(warned_calls),
830
+ "blocked": sorted(blocked_calls),
831
+ "warned": sorted(warned_calls),
832
+ },
833
+ "dunders": {
834
+ "allowed": sorted(dunders),
835
+ },
836
+ "tool_namespaces": sorted(tool_namespaces),
837
+ }