proxilion 0.0.1__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 (94) hide show
  1. proxilion/__init__.py +136 -0
  2. proxilion/audit/__init__.py +133 -0
  3. proxilion/audit/base_exporters.py +527 -0
  4. proxilion/audit/compliance/__init__.py +130 -0
  5. proxilion/audit/compliance/base.py +457 -0
  6. proxilion/audit/compliance/eu_ai_act.py +603 -0
  7. proxilion/audit/compliance/iso27001.py +544 -0
  8. proxilion/audit/compliance/soc2.py +491 -0
  9. proxilion/audit/events.py +493 -0
  10. proxilion/audit/explainability.py +1173 -0
  11. proxilion/audit/exporters/__init__.py +58 -0
  12. proxilion/audit/exporters/aws_s3.py +636 -0
  13. proxilion/audit/exporters/azure_storage.py +608 -0
  14. proxilion/audit/exporters/cloud_base.py +468 -0
  15. proxilion/audit/exporters/gcp_storage.py +570 -0
  16. proxilion/audit/exporters/multi_exporter.py +498 -0
  17. proxilion/audit/hash_chain.py +652 -0
  18. proxilion/audit/logger.py +543 -0
  19. proxilion/caching/__init__.py +49 -0
  20. proxilion/caching/tool_cache.py +633 -0
  21. proxilion/context/__init__.py +73 -0
  22. proxilion/context/context_window.py +556 -0
  23. proxilion/context/message_history.py +505 -0
  24. proxilion/context/session.py +735 -0
  25. proxilion/contrib/__init__.py +51 -0
  26. proxilion/contrib/anthropic.py +609 -0
  27. proxilion/contrib/google.py +1012 -0
  28. proxilion/contrib/langchain.py +641 -0
  29. proxilion/contrib/mcp.py +893 -0
  30. proxilion/contrib/openai.py +646 -0
  31. proxilion/core.py +3058 -0
  32. proxilion/decorators.py +966 -0
  33. proxilion/engines/__init__.py +287 -0
  34. proxilion/engines/base.py +266 -0
  35. proxilion/engines/casbin_engine.py +412 -0
  36. proxilion/engines/opa_engine.py +493 -0
  37. proxilion/engines/simple.py +437 -0
  38. proxilion/exceptions.py +887 -0
  39. proxilion/guards/__init__.py +54 -0
  40. proxilion/guards/input_guard.py +522 -0
  41. proxilion/guards/output_guard.py +634 -0
  42. proxilion/observability/__init__.py +198 -0
  43. proxilion/observability/cost_tracker.py +866 -0
  44. proxilion/observability/hooks.py +683 -0
  45. proxilion/observability/metrics.py +798 -0
  46. proxilion/observability/session_cost_tracker.py +1063 -0
  47. proxilion/policies/__init__.py +67 -0
  48. proxilion/policies/base.py +304 -0
  49. proxilion/policies/builtin.py +486 -0
  50. proxilion/policies/registry.py +376 -0
  51. proxilion/providers/__init__.py +201 -0
  52. proxilion/providers/adapter.py +468 -0
  53. proxilion/providers/anthropic_adapter.py +330 -0
  54. proxilion/providers/gemini_adapter.py +391 -0
  55. proxilion/providers/openai_adapter.py +294 -0
  56. proxilion/py.typed +0 -0
  57. proxilion/resilience/__init__.py +81 -0
  58. proxilion/resilience/degradation.py +615 -0
  59. proxilion/resilience/fallback.py +555 -0
  60. proxilion/resilience/retry.py +554 -0
  61. proxilion/scheduling/__init__.py +57 -0
  62. proxilion/scheduling/priority_queue.py +419 -0
  63. proxilion/scheduling/scheduler.py +459 -0
  64. proxilion/security/__init__.py +244 -0
  65. proxilion/security/agent_trust.py +968 -0
  66. proxilion/security/behavioral_drift.py +794 -0
  67. proxilion/security/cascade_protection.py +869 -0
  68. proxilion/security/circuit_breaker.py +428 -0
  69. proxilion/security/cost_limiter.py +690 -0
  70. proxilion/security/idor_protection.py +460 -0
  71. proxilion/security/intent_capsule.py +849 -0
  72. proxilion/security/intent_validator.py +495 -0
  73. proxilion/security/memory_integrity.py +767 -0
  74. proxilion/security/rate_limiter.py +509 -0
  75. proxilion/security/scope_enforcer.py +680 -0
  76. proxilion/security/sequence_validator.py +636 -0
  77. proxilion/security/trust_boundaries.py +784 -0
  78. proxilion/streaming/__init__.py +70 -0
  79. proxilion/streaming/detector.py +761 -0
  80. proxilion/streaming/transformer.py +674 -0
  81. proxilion/timeouts/__init__.py +55 -0
  82. proxilion/timeouts/decorators.py +477 -0
  83. proxilion/timeouts/manager.py +545 -0
  84. proxilion/tools/__init__.py +69 -0
  85. proxilion/tools/decorators.py +493 -0
  86. proxilion/tools/registry.py +732 -0
  87. proxilion/types.py +339 -0
  88. proxilion/validation/__init__.py +93 -0
  89. proxilion/validation/pydantic_schema.py +351 -0
  90. proxilion/validation/schema.py +651 -0
  91. proxilion-0.0.1.dist-info/METADATA +872 -0
  92. proxilion-0.0.1.dist-info/RECORD +94 -0
  93. proxilion-0.0.1.dist-info/WHEEL +4 -0
  94. proxilion-0.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,680 @@
1
+ """
2
+ Semantic scope enforcement for Proxilion.
3
+
4
+ Binds tool calls to execution scopes (read_only, read_write, admin) so that
5
+ even if an agent has access to a tool, it can only use it within the scope
6
+ of the current operation.
7
+
8
+ Addresses:
9
+ - OWASP ASI02 (Tool Misuse)
10
+ - Principle of Least Privilege
11
+
12
+ Example:
13
+ >>> from proxilion.security.scope_enforcer import (
14
+ ... ScopeEnforcer, ExecutionScope, scoped_execution
15
+ ... )
16
+ >>> from proxilion import UserContext
17
+ >>>
18
+ >>> enforcer = ScopeEnforcer()
19
+ >>>
20
+ >>> # In a read-only scope, only read operations are allowed
21
+ >>> user = UserContext(user_id="user_123", roles=["viewer"])
22
+ >>> with scoped_execution(enforcer, "read_only", user) as ctx:
23
+ ... ctx.validate_tool("get_user") # OK
24
+ ... ctx.validate_tool("delete_user") # Raises ScopeViolationError
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import fnmatch
30
+ import logging
31
+ import re
32
+ import threading
33
+ from collections.abc import Generator
34
+ from contextlib import contextmanager
35
+ from dataclasses import dataclass, field
36
+ from enum import Enum
37
+ from typing import Any
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+
42
+ class ExecutionScope(Enum):
43
+ """
44
+ Predefined execution scopes for tool calls.
45
+
46
+ Scopes define what categories of operations are permitted.
47
+ """
48
+
49
+ READ_ONLY = "read_only"
50
+ """Only read operations (get, list, search, query)."""
51
+
52
+ READ_WRITE = "read_write"
53
+ """Read and write operations (create, update, modify)."""
54
+
55
+ ADMIN = "admin"
56
+ """All operations including delete and execute."""
57
+
58
+ CUSTOM = "custom"
59
+ """Custom scope with explicit allow/deny lists."""
60
+
61
+
62
+ @dataclass
63
+ class ScopeBinding:
64
+ """
65
+ Binding between a scope and its allowed/denied operations.
66
+
67
+ Attributes:
68
+ scope: The execution scope this binding represents.
69
+ allowed_tools: Set of tool name patterns that are allowed.
70
+ denied_tools: Set of tool name patterns that are denied.
71
+ allowed_actions: Set of action names that are allowed.
72
+ denied_actions: Set of action names that are denied.
73
+ name: Optional name for the scope binding.
74
+ description: Optional description.
75
+ """
76
+
77
+ scope: ExecutionScope
78
+ allowed_tools: set[str] = field(default_factory=set)
79
+ denied_tools: set[str] = field(default_factory=set)
80
+ allowed_actions: set[str] = field(default_factory=set)
81
+ denied_actions: set[str] = field(default_factory=set)
82
+ name: str = ""
83
+ description: str = ""
84
+
85
+ def allows_action(self, action: str) -> bool:
86
+ """Check if an action is allowed in this scope."""
87
+ # If wildcard allowed, everything is allowed
88
+ if "*" in self.allowed_actions:
89
+ return action.lower() not in {a.lower() for a in self.denied_actions}
90
+
91
+ # Check if explicitly denied
92
+ if action.lower() in {a.lower() for a in self.denied_actions}:
93
+ return False
94
+
95
+ # Check if explicitly allowed
96
+ if action.lower() in {a.lower() for a in self.allowed_actions}:
97
+ return True
98
+
99
+ # If allowed_actions is non-empty and action not in it, deny
100
+ if self.allowed_actions:
101
+ return False
102
+
103
+ # Default to allow if no restrictions specified
104
+ return True
105
+
106
+ def allows_tool(self, tool_name: str) -> bool:
107
+ """Check if a tool is allowed in this scope."""
108
+ tool_lower = tool_name.lower()
109
+
110
+ # Check if explicitly denied by pattern
111
+ for pattern in self.denied_tools:
112
+ if fnmatch.fnmatch(tool_lower, pattern.lower()):
113
+ return False
114
+
115
+ # Check if explicitly allowed by pattern
116
+ if self.allowed_tools:
117
+ for pattern in self.allowed_tools:
118
+ if pattern == "*" or fnmatch.fnmatch(tool_lower, pattern.lower()):
119
+ return True
120
+ return False # Not in allowed list
121
+
122
+ # Default to allow if no tool restrictions
123
+ return True
124
+
125
+
126
+ @dataclass
127
+ class ToolClassification:
128
+ """
129
+ Classification of a tool's default scope and actions.
130
+
131
+ Attributes:
132
+ tool_name: Name of the tool.
133
+ default_scope: Default scope the tool belongs to.
134
+ actions: Set of actions the tool can perform.
135
+ """
136
+
137
+ tool_name: str
138
+ default_scope: ExecutionScope
139
+ actions: set[str] = field(default_factory=set)
140
+
141
+
142
+ # Built-in scope bindings
143
+ BUILTIN_SCOPES: dict[str, ScopeBinding] = {
144
+ "read_only": ScopeBinding(
145
+ scope=ExecutionScope.READ_ONLY,
146
+ allowed_actions={"read", "list", "get", "search", "query", "fetch", "find"},
147
+ denied_actions={
148
+ "write", "delete", "execute", "modify", "create", "update", "remove", "drop",
149
+ },
150
+ name="read_only",
151
+ description="Read-only operations only",
152
+ ),
153
+ "read_write": ScopeBinding(
154
+ scope=ExecutionScope.READ_WRITE,
155
+ allowed_actions={
156
+ "read", "list", "get", "search", "query", "fetch", "find",
157
+ "write", "create", "modify", "update", "add", "set",
158
+ },
159
+ denied_actions={"delete", "execute", "remove", "drop", "destroy", "run"},
160
+ name="read_write",
161
+ description="Read and write operations, no delete or execute",
162
+ ),
163
+ "admin": ScopeBinding(
164
+ scope=ExecutionScope.ADMIN,
165
+ allowed_actions={"*"},
166
+ denied_actions=set(),
167
+ name="admin",
168
+ description="All operations allowed",
169
+ ),
170
+ }
171
+
172
+
173
+ # Default tool classification patterns
174
+ DEFAULT_TOOL_CLASSIFICATIONS: dict[str, ExecutionScope] = {
175
+ r"^(get|read|list|search|query|fetch|find|check|view|show)_": ExecutionScope.READ_ONLY,
176
+ r"^(create|write|update|modify|set|add|put|insert|save)_": ExecutionScope.READ_WRITE,
177
+ r"^(delete|remove|drop|destroy|execute|run|kill|terminate|purge)_": ExecutionScope.ADMIN,
178
+ }
179
+
180
+
181
+ class ScopeEnforcer:
182
+ """
183
+ Enforces execution scopes on tool calls.
184
+
185
+ The ScopeEnforcer validates that tool calls are permitted within
186
+ the current execution scope based on tool names, actions, and
187
+ explicit allow/deny rules.
188
+
189
+ Example:
190
+ >>> enforcer = ScopeEnforcer()
191
+ >>>
192
+ >>> # Validate a tool in read_only scope
193
+ >>> scope = enforcer.get_scope("read_only")
194
+ >>> allowed, reason = enforcer.validate_in_scope("get_user", "read", scope)
195
+ >>> print(allowed) # True
196
+ >>>
197
+ >>> # Try write operation in read_only scope
198
+ >>> allowed, reason = enforcer.validate_in_scope("update_user", "write", scope)
199
+ >>> print(allowed) # False
200
+ """
201
+
202
+ def __init__(
203
+ self,
204
+ default_scope: ExecutionScope = ExecutionScope.READ_ONLY,
205
+ custom_scopes: dict[str, ScopeBinding] | None = None,
206
+ tool_classifications: dict[str, ExecutionScope] | None = None,
207
+ ) -> None:
208
+ """
209
+ Initialize the scope enforcer.
210
+
211
+ Args:
212
+ default_scope: Default scope when no scope is specified.
213
+ custom_scopes: Additional custom scope bindings.
214
+ tool_classifications: Custom tool name patterns to scope mappings.
215
+ """
216
+ self._default_scope = default_scope
217
+ self._lock = threading.RLock()
218
+
219
+ # Initialize scopes with built-ins
220
+ self._scopes: dict[str, ScopeBinding] = dict(BUILTIN_SCOPES)
221
+ if custom_scopes:
222
+ self._scopes.update(custom_scopes)
223
+
224
+ # Initialize tool classifications
225
+ self._tool_classifications: dict[str, ExecutionScope] = dict(DEFAULT_TOOL_CLASSIFICATIONS)
226
+ if tool_classifications:
227
+ self._tool_classifications.update(tool_classifications)
228
+
229
+ # Cache for classified tools
230
+ self._tool_scope_cache: dict[str, ExecutionScope] = {}
231
+
232
+ def get_scope(self, scope: str | ExecutionScope) -> ScopeBinding:
233
+ """
234
+ Get a scope binding by name or enum.
235
+
236
+ Args:
237
+ scope: Scope name string or ExecutionScope enum.
238
+
239
+ Returns:
240
+ The ScopeBinding for the requested scope.
241
+
242
+ Raises:
243
+ ValueError: If scope is not found.
244
+ """
245
+ with self._lock:
246
+ if isinstance(scope, ExecutionScope):
247
+ # Return built-in scope for enum
248
+ scope_name = scope.value
249
+ else:
250
+ scope_name = scope.lower()
251
+
252
+ if scope_name in self._scopes:
253
+ return self._scopes[scope_name]
254
+
255
+ raise ValueError(f"Unknown scope: {scope}")
256
+
257
+ def create_scope(
258
+ self,
259
+ name: str,
260
+ allowed_tools: set[str] | None = None,
261
+ denied_tools: set[str] | None = None,
262
+ allowed_actions: set[str] | None = None,
263
+ denied_actions: set[str] | None = None,
264
+ base_scope: ExecutionScope = ExecutionScope.CUSTOM,
265
+ description: str = "",
266
+ ) -> ScopeBinding:
267
+ """
268
+ Create a custom scope binding.
269
+
270
+ Args:
271
+ name: Unique name for the scope.
272
+ allowed_tools: Set of tool patterns allowed.
273
+ denied_tools: Set of tool patterns denied.
274
+ allowed_actions: Set of actions allowed.
275
+ denied_actions: Set of actions denied.
276
+ base_scope: Base scope type.
277
+ description: Human-readable description.
278
+
279
+ Returns:
280
+ The created ScopeBinding.
281
+ """
282
+ binding = ScopeBinding(
283
+ scope=base_scope,
284
+ allowed_tools=allowed_tools or set(),
285
+ denied_tools=denied_tools or set(),
286
+ allowed_actions=allowed_actions or set(),
287
+ denied_actions=denied_actions or set(),
288
+ name=name,
289
+ description=description,
290
+ )
291
+
292
+ with self._lock:
293
+ self._scopes[name.lower()] = binding
294
+
295
+ return binding
296
+
297
+ def create_scope_from_enum(self, scope: ExecutionScope) -> ScopeBinding:
298
+ """
299
+ Create a scope binding from an ExecutionScope enum.
300
+
301
+ Args:
302
+ scope: The ExecutionScope enum value.
303
+
304
+ Returns:
305
+ A ScopeBinding matching the enum.
306
+ """
307
+ return self.get_scope(scope.value)
308
+
309
+ def classify_tool(
310
+ self,
311
+ tool_name: str,
312
+ scope: ExecutionScope | None = None,
313
+ actions: set[str] | None = None,
314
+ ) -> ToolClassification:
315
+ """
316
+ Classify a tool by name pattern or explicit assignment.
317
+
318
+ Args:
319
+ tool_name: Name of the tool.
320
+ scope: Explicit scope assignment (overrides pattern matching).
321
+ actions: Set of actions the tool performs.
322
+
323
+ Returns:
324
+ ToolClassification for the tool.
325
+ """
326
+ with self._lock:
327
+ if scope is not None:
328
+ # Explicit scope assignment
329
+ self._tool_scope_cache[tool_name.lower()] = scope
330
+ return ToolClassification(
331
+ tool_name=tool_name,
332
+ default_scope=scope,
333
+ actions=actions or set(),
334
+ )
335
+
336
+ # Check cache first
337
+ if tool_name.lower() in self._tool_scope_cache:
338
+ return ToolClassification(
339
+ tool_name=tool_name,
340
+ default_scope=self._tool_scope_cache[tool_name.lower()],
341
+ actions=actions or set(),
342
+ )
343
+
344
+ # Pattern matching
345
+ for pattern, pattern_scope in self._tool_classifications.items():
346
+ if re.match(pattern, tool_name, re.IGNORECASE):
347
+ self._tool_scope_cache[tool_name.lower()] = pattern_scope
348
+ return ToolClassification(
349
+ tool_name=tool_name,
350
+ default_scope=pattern_scope,
351
+ actions=actions or self._infer_actions(tool_name),
352
+ )
353
+
354
+ # Default to read_only for unknown tools
355
+ return ToolClassification(
356
+ tool_name=tool_name,
357
+ default_scope=self._default_scope,
358
+ actions=actions or set(),
359
+ )
360
+
361
+ def _infer_actions(self, tool_name: str) -> set[str]:
362
+ """Infer actions from tool name prefix."""
363
+ tool_lower = tool_name.lower()
364
+ actions = set()
365
+
366
+ read_prefixes = ("get_", "read_", "list_", "search_", "query_", "fetch_", "find_")
367
+ if any(tool_lower.startswith(p) for p in read_prefixes):
368
+ actions.add("read")
369
+ if any(tool_lower.startswith(p) for p in ("create_", "write_", "add_", "insert_", "save_")):
370
+ actions.add("write")
371
+ actions.add("create")
372
+ if any(tool_lower.startswith(p) for p in ("update_", "modify_", "set_", "put_")):
373
+ actions.add("write")
374
+ actions.add("modify")
375
+ delete_prefixes = ("delete_", "remove_", "drop_", "destroy_", "purge_")
376
+ if any(tool_lower.startswith(p) for p in delete_prefixes):
377
+ actions.add("delete")
378
+ if any(tool_lower.startswith(p) for p in ("execute_", "run_", "kill_", "terminate_")):
379
+ actions.add("execute")
380
+
381
+ return actions or {"execute"} # Default to execute if unknown
382
+
383
+ def validate_in_scope(
384
+ self,
385
+ tool_name: str,
386
+ action: str,
387
+ scope: ScopeBinding,
388
+ ) -> tuple[bool, str | None]:
389
+ """
390
+ Validate if a tool call is allowed in the given scope.
391
+
392
+ Args:
393
+ tool_name: Name of the tool to validate.
394
+ action: The action being performed.
395
+ scope: The scope binding to validate against.
396
+
397
+ Returns:
398
+ Tuple of (allowed, reason). If not allowed, reason explains why.
399
+ """
400
+ # Check if tool is explicitly denied
401
+ if not scope.allows_tool(tool_name):
402
+ scope_id = scope.name or scope.scope.value
403
+ return False, f"Tool '{tool_name}' is not allowed in scope '{scope_id}'"
404
+
405
+ # Check if action is allowed
406
+ if not scope.allows_action(action):
407
+ scope_id = scope.name or scope.scope.value
408
+ return False, f"Action '{action}' is not allowed in scope '{scope_id}'"
409
+
410
+ # Check tool classification against scope
411
+ classification = self.classify_tool(tool_name)
412
+ tool_scope = classification.default_scope
413
+
414
+ # Scope hierarchy: READ_ONLY < READ_WRITE < ADMIN
415
+ scope_levels = {
416
+ ExecutionScope.READ_ONLY: 1,
417
+ ExecutionScope.READ_WRITE: 2,
418
+ ExecutionScope.ADMIN: 3,
419
+ ExecutionScope.CUSTOM: 0, # Custom scopes use explicit rules
420
+ }
421
+
422
+ current_level = scope_levels.get(scope.scope, 0)
423
+ required_level = scope_levels.get(tool_scope, 0)
424
+
425
+ # Custom scopes only use explicit allow/deny rules
426
+ if scope.scope == ExecutionScope.CUSTOM:
427
+ return True, None
428
+
429
+ # Check if current scope level is sufficient
430
+ if required_level > current_level:
431
+ return False, (
432
+ f"Tool '{tool_name}' requires '{tool_scope.value}' scope, "
433
+ f"but current scope is '{scope.scope.value}'"
434
+ )
435
+
436
+ return True, None
437
+
438
+ def get_allowed_tools(self, scope: ScopeBinding) -> set[str]:
439
+ """
440
+ Get the set of allowed tool patterns for a scope.
441
+
442
+ Args:
443
+ scope: The scope binding.
444
+
445
+ Returns:
446
+ Set of allowed tool patterns.
447
+ """
448
+ if scope.allowed_tools:
449
+ return set(scope.allowed_tools)
450
+
451
+ # If no explicit allowed tools, return patterns based on scope level
452
+ if scope.scope == ExecutionScope.READ_ONLY:
453
+ return {"get_*", "read_*", "list_*", "search_*", "query_*", "fetch_*", "find_*"}
454
+ elif scope.scope == ExecutionScope.READ_WRITE:
455
+ return {
456
+ "get_*", "read_*", "list_*", "search_*", "query_*", "fetch_*", "find_*",
457
+ "create_*", "write_*", "update_*", "modify_*", "set_*", "add_*",
458
+ }
459
+ elif scope.scope == ExecutionScope.ADMIN:
460
+ return {"*"}
461
+
462
+ return set()
463
+
464
+ def add_scope(self, name: str, binding: ScopeBinding) -> None:
465
+ """
466
+ Add a scope binding.
467
+
468
+ Args:
469
+ name: Name for the scope.
470
+ binding: The ScopeBinding to add.
471
+ """
472
+ with self._lock:
473
+ self._scopes[name.lower()] = binding
474
+
475
+ def remove_scope(self, name: str) -> bool:
476
+ """
477
+ Remove a custom scope.
478
+
479
+ Args:
480
+ name: Name of the scope to remove.
481
+
482
+ Returns:
483
+ True if removed, False if not found or built-in.
484
+ """
485
+ name_lower = name.lower()
486
+ if name_lower in BUILTIN_SCOPES:
487
+ return False # Cannot remove built-in scopes
488
+
489
+ with self._lock:
490
+ if name_lower in self._scopes:
491
+ del self._scopes[name_lower]
492
+ return True
493
+ return False
494
+
495
+ def get_scopes(self) -> dict[str, ScopeBinding]:
496
+ """Get all registered scopes."""
497
+ with self._lock:
498
+ return dict(self._scopes)
499
+
500
+ def add_tool_classification(self, pattern: str, scope: ExecutionScope) -> None:
501
+ """
502
+ Add a tool classification pattern.
503
+
504
+ Args:
505
+ pattern: Regex pattern to match tool names.
506
+ scope: Scope to assign to matching tools.
507
+ """
508
+ with self._lock:
509
+ self._tool_classifications[pattern] = scope
510
+ # Clear cache as classifications changed
511
+ self._tool_scope_cache.clear()
512
+
513
+
514
+ class ScopeContext:
515
+ """
516
+ Context for scoped execution.
517
+
518
+ Tracks tool calls within a scope and validates them against
519
+ the scope's rules.
520
+
521
+ Example:
522
+ >>> ctx = ScopeContext(enforcer, scope, user)
523
+ >>> ctx.validate_tool("get_user") # Returns True
524
+ >>> ctx.validate_tool("delete_user") # Raises ScopeViolationError
525
+ >>> print(ctx.get_calls()) # ["get_user"]
526
+ """
527
+
528
+ def __init__(
529
+ self,
530
+ enforcer: ScopeEnforcer,
531
+ scope: ScopeBinding,
532
+ user: Any,
533
+ ) -> None:
534
+ """
535
+ Initialize scope context.
536
+
537
+ Args:
538
+ enforcer: The ScopeEnforcer instance.
539
+ scope: The scope binding for this context.
540
+ user: The user context.
541
+ """
542
+ self.enforcer = enforcer
543
+ self.scope = scope
544
+ self.user = user
545
+ self._calls: list[tuple[str, str]] = [] # (tool_name, action)
546
+ self._closed = False
547
+
548
+ def validate_tool(self, tool_name: str, action: str = "execute") -> bool:
549
+ """
550
+ Validate a tool call within this scope.
551
+
552
+ Args:
553
+ tool_name: Name of the tool.
554
+ action: Action being performed.
555
+
556
+ Returns:
557
+ True if allowed.
558
+
559
+ Raises:
560
+ ScopeViolationError: If the tool call is not allowed.
561
+ """
562
+ if self._closed:
563
+ raise RuntimeError("ScopeContext is closed")
564
+
565
+ # Import here to avoid circular import
566
+ from proxilion.exceptions import ScopeViolationError
567
+
568
+ allowed, reason = self.enforcer.validate_in_scope(tool_name, action, self.scope)
569
+ if not allowed:
570
+ raise ScopeViolationError(
571
+ tool_name=tool_name,
572
+ scope_name=self.scope.name or self.scope.scope.value,
573
+ reason=reason,
574
+ )
575
+
576
+ self._calls.append((tool_name, action))
577
+ logger.debug(
578
+ f"Tool '{tool_name}' (action: {action}) validated in scope '{self.scope.name}'"
579
+ )
580
+ return True
581
+
582
+ def is_tool_allowed(self, tool_name: str, action: str = "execute") -> bool:
583
+ """
584
+ Check if a tool is allowed without raising an exception.
585
+
586
+ Args:
587
+ tool_name: Name of the tool.
588
+ action: Action being performed.
589
+
590
+ Returns:
591
+ True if allowed, False otherwise.
592
+ """
593
+ if self._closed:
594
+ return False
595
+
596
+ allowed, _ = self.enforcer.validate_in_scope(tool_name, action, self.scope)
597
+ return allowed
598
+
599
+ def get_calls(self) -> list[tuple[str, str]]:
600
+ """Get all validated tool calls in this context."""
601
+ return list(self._calls)
602
+
603
+ def get_tool_names(self) -> list[str]:
604
+ """Get just the tool names from validated calls."""
605
+ return [call[0] for call in self._calls]
606
+
607
+ def close(self) -> None:
608
+ """Close the scope context."""
609
+ self._closed = True
610
+ logger.debug(
611
+ f"ScopeContext closed. Total calls: {len(self._calls)}"
612
+ )
613
+
614
+ @property
615
+ def is_closed(self) -> bool:
616
+ """Check if context is closed."""
617
+ return self._closed
618
+
619
+
620
+ @contextmanager
621
+ def scoped_execution(
622
+ enforcer: ScopeEnforcer,
623
+ scope: ExecutionScope | str,
624
+ user: Any,
625
+ ) -> Generator[ScopeContext, None, None]:
626
+ """
627
+ Context manager for scoped tool execution.
628
+
629
+ Creates a scope context that validates all tool calls against
630
+ the specified scope's rules.
631
+
632
+ Args:
633
+ enforcer: The ScopeEnforcer instance.
634
+ scope: Scope name or ExecutionScope enum.
635
+ user: The user context for this execution.
636
+
637
+ Yields:
638
+ ScopeContext for validating tool calls.
639
+
640
+ Example:
641
+ >>> with scoped_execution(enforcer, "read_only", user) as ctx:
642
+ ... ctx.validate_tool("get_user") # OK
643
+ ... ctx.validate_tool("delete_user") # Raises ScopeViolationError
644
+ """
645
+ if isinstance(scope, str):
646
+ scope_binding = enforcer.get_scope(scope)
647
+ else:
648
+ scope_binding = enforcer.create_scope_from_enum(scope)
649
+
650
+ ctx = ScopeContext(enforcer, scope_binding, user)
651
+ logger.debug(f"Entering scope '{scope_binding.name or scope_binding.scope.value}'")
652
+
653
+ try:
654
+ yield ctx
655
+ finally:
656
+ ctx.close()
657
+ logger.debug(
658
+ f"Exited scope '{scope_binding.name or scope_binding.scope.value}' "
659
+ f"with {len(ctx.get_calls())} tool calls"
660
+ )
661
+
662
+
663
+ def create_scope_enforcer(
664
+ default_scope: ExecutionScope = ExecutionScope.READ_ONLY,
665
+ custom_scopes: dict[str, ScopeBinding] | None = None,
666
+ ) -> ScopeEnforcer:
667
+ """
668
+ Factory function to create a ScopeEnforcer.
669
+
670
+ Args:
671
+ default_scope: Default scope for unknown tools.
672
+ custom_scopes: Additional custom scope bindings.
673
+
674
+ Returns:
675
+ Configured ScopeEnforcer instance.
676
+ """
677
+ return ScopeEnforcer(
678
+ default_scope=default_scope,
679
+ custom_scopes=custom_scopes,
680
+ )