agenthandler 0.92.0__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.

Potentially problematic release.


This version of agenthandler might be problematic. Click here for more details.

@@ -0,0 +1,472 @@
1
+ """Deterministic guardrails — rule-based safety checks.
2
+
3
+ All eight run synchronously with zero overhead when not configured.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import fnmatch
9
+ import hashlib
10
+ import json
11
+ import re
12
+ import threading
13
+ import time
14
+ from dataclasses import dataclass, field
15
+ from datetime import datetime, timezone
16
+ from typing import Any, Dict, List, Optional, Set, Tuple
17
+ from urllib.parse import urlparse
18
+
19
+ from .types import GuardrailResult
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # 1. Output schema validation
23
+ # ---------------------------------------------------------------------------
24
+
25
+
26
+ class SchemaValidator:
27
+ """Validate tool outputs against a JSON schema.
28
+
29
+ Blocks outputs that don't match the expected structure. Catches
30
+ broken tools, corrupted data, or LLM hallucinations.
31
+
32
+ Supports a minimal JSON Schema subset: type, required, properties,
33
+ items, minLength, maxLength, minimum, maximum, enum, pattern.
34
+
35
+ Usage:
36
+ validator = SchemaValidator({
37
+ "search_results": {
38
+ "type": "object",
39
+ "required": ["rows"],
40
+ "properties": {
41
+ "rows": {"type": "array"},
42
+ "count": {"type": "integer", "minimum": 0},
43
+ },
44
+ },
45
+ })
46
+ """
47
+
48
+ def __init__(self, schemas: Optional[Dict[str, Dict[str, Any]]] = None):
49
+ self.name = "schema_validator"
50
+ self._schemas = schemas or {}
51
+
52
+ def check(
53
+ self,
54
+ tool_name: str,
55
+ kwargs: Dict[str, Any],
56
+ output: Any,
57
+ context: Dict[str, Any],
58
+ ) -> GuardrailResult:
59
+ schema = self._schemas.get(tool_name)
60
+ if schema is None:
61
+ return GuardrailResult.allow(self.name)
62
+
63
+ error = _validate_json_schema(output, schema)
64
+ if error:
65
+ return GuardrailResult.block(f"Output schema validation failed: {error}", self.name)
66
+ return GuardrailResult.allow(self.name)
67
+
68
+
69
+ def _validate_json_schema(value: Any, schema: Dict[str, Any], path: str = "$") -> str:
70
+ """Minimal JSON Schema validator. Returns empty string if valid, error if not."""
71
+ stype = schema.get("type")
72
+ if stype:
73
+ type_map: Dict[str, Any] = {
74
+ "object": dict,
75
+ "array": list,
76
+ "string": str,
77
+ "integer": int,
78
+ "number": (int, float),
79
+ "boolean": bool,
80
+ "null": type(None),
81
+ }
82
+ expected = type_map.get(stype)
83
+ if expected is not None and not isinstance(value, expected):
84
+ return f"{path}: expected {stype}, got {type(value).__name__}"
85
+
86
+ if stype == "object" and isinstance(value, dict):
87
+ for req in schema.get("required", []):
88
+ if req not in value:
89
+ return f"{path}: missing required field '{req}'"
90
+ for key, subschema in schema.get("properties", {}).items():
91
+ if key in value:
92
+ err = _validate_json_schema(value[key], subschema, f"{path}.{key}")
93
+ if err:
94
+ return err
95
+
96
+ if stype == "array" and isinstance(value, list):
97
+ item_schema = schema.get("items")
98
+ if item_schema:
99
+ for i, item in enumerate(value):
100
+ err = _validate_json_schema(item, item_schema, f"{path}[{i}]")
101
+ if err:
102
+ return err
103
+
104
+ if isinstance(value, str):
105
+ min_len = schema.get("minLength")
106
+ max_len = schema.get("maxLength")
107
+ if min_len is not None and len(value) < min_len:
108
+ return f"{path}: string too short (< {min_len})"
109
+ if max_len is not None and len(value) > max_len:
110
+ return f"{path}: string too long (> {max_len})"
111
+ pattern = schema.get("pattern")
112
+ if pattern and not re.search(pattern, value):
113
+ return f"{path}: does not match pattern {pattern}"
114
+
115
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
116
+ minimum = schema.get("minimum")
117
+ maximum = schema.get("maximum")
118
+ if minimum is not None and value < minimum:
119
+ return f"{path}: value {value} < minimum {minimum}"
120
+ if maximum is not None and value > maximum:
121
+ return f"{path}: value {value} > maximum {maximum}"
122
+
123
+ enum = schema.get("enum")
124
+ if enum is not None and value not in enum:
125
+ return f"{path}: value {value} not in enum {enum}"
126
+
127
+ return ""
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # 2. Category rate limiting
132
+ # ---------------------------------------------------------------------------
133
+
134
+
135
+ @dataclass
136
+ class CategoryRateLimit:
137
+ """Rate limit across tool categories.
138
+
139
+ Unlike per-tool rate limits, this tracks calls across related tools.
140
+ For example, limit all "network" tools combined to 100/minute.
141
+
142
+ Usage:
143
+ CategoryRateLimit(
144
+ categories={
145
+ "network": ["web_search", "fetch_url", "http_post"],
146
+ "writes": ["write_file", "delete_file", "send_email"],
147
+ },
148
+ limits={"network": 100, "writes": 10}, # per minute
149
+ )
150
+ """
151
+
152
+ categories: Dict[str, List[str]] = field(default_factory=dict)
153
+ limits: Dict[str, int] = field(default_factory=dict)
154
+ _call_times: Dict[str, List[float]] = field(default_factory=dict)
155
+ _lock: threading.Lock = field(default_factory=threading.Lock)
156
+ name: str = "category_rate_limit"
157
+
158
+ def check(
159
+ self, tool_name: str, kwargs: Dict[str, Any], context: Dict[str, Any]
160
+ ) -> GuardrailResult:
161
+ now = time.monotonic()
162
+ # Find which categories this tool belongs to
163
+ for category, tools in self.categories.items():
164
+ if tool_name in tools:
165
+ limit = self.limits.get(category)
166
+ if limit is None:
167
+ continue
168
+ with self._lock:
169
+ times = self._call_times.setdefault(category, [])
170
+ cutoff = now - 60.0
171
+ recent = [t for t in times if t > cutoff]
172
+ if len(recent) >= limit:
173
+ return GuardrailResult.block(
174
+ f"Category '{category}' rate limit exceeded: "
175
+ f"{len(recent)}/{limit} per minute",
176
+ self.name,
177
+ )
178
+ recent.append(now)
179
+ self._call_times[category] = recent
180
+ return GuardrailResult.allow(self.name)
181
+
182
+
183
+ # ---------------------------------------------------------------------------
184
+ # 3. Recursion / loop detection
185
+ # ---------------------------------------------------------------------------
186
+
187
+
188
+ @dataclass
189
+ class RecursionDetector:
190
+ """Detect tool call loops.
191
+
192
+ Tracks recent tool call sequences. Blocks if the same (tool, args_hash)
193
+ appears more than ``max_repeats`` times within ``window`` seconds.
194
+
195
+ Catches agents that get stuck calling the same thing over and over.
196
+ """
197
+
198
+ max_repeats: int = 3
199
+ window: float = 30.0
200
+ _history: List[Tuple[float, str, str]] = field(default_factory=list)
201
+ _lock: threading.Lock = field(default_factory=threading.Lock)
202
+ name: str = "recursion_detector"
203
+
204
+ def check(
205
+ self, tool_name: str, kwargs: Dict[str, Any], context: Dict[str, Any]
206
+ ) -> GuardrailResult:
207
+ now = time.monotonic()
208
+ sig = hashlib.sha256(
209
+ (tool_name + json.dumps(kwargs, sort_keys=True, default=str)).encode()
210
+ ).hexdigest()[:16]
211
+
212
+ with self._lock:
213
+ cutoff = now - self.window
214
+ self._history = [(t, n, s) for t, n, s in self._history if t > cutoff]
215
+ repeats = sum(1 for _, n, s in self._history if n == tool_name and s == sig)
216
+ if repeats >= self.max_repeats:
217
+ return GuardrailResult.block(
218
+ f"Recursion detected: {tool_name} called {repeats + 1} times "
219
+ f"with identical args in {self.window}s",
220
+ self.name,
221
+ )
222
+ self._history.append((now, tool_name, sig))
223
+ return GuardrailResult.allow(self.name)
224
+
225
+
226
+ # ---------------------------------------------------------------------------
227
+ # 4. Idempotency keys
228
+ # ---------------------------------------------------------------------------
229
+
230
+
231
+ @dataclass
232
+ class IdempotencyGuard:
233
+ """Cache results of mutating tool calls to dedupe retries.
234
+
235
+ If the same (tool, args) is called within the TTL window, returns
236
+ the cached result instead of executing again. Useful for tools that
237
+ are expensive or have side effects (send_email, charge_card).
238
+
239
+ Only applies to tools in ``tracked_tools``.
240
+ """
241
+
242
+ tracked_tools: List[str] = field(default_factory=list)
243
+ ttl_seconds: float = 300.0
244
+ _cache: Dict[str, Tuple[float, Any]] = field(default_factory=dict)
245
+ _lock: threading.Lock = field(default_factory=threading.Lock)
246
+ name: str = "idempotency_guard"
247
+
248
+ def _key(self, tool_name: str, kwargs: Dict[str, Any]) -> str:
249
+ return hashlib.sha256(
250
+ (tool_name + json.dumps(kwargs, sort_keys=True, default=str)).encode()
251
+ ).hexdigest()
252
+
253
+ def check_pre(
254
+ self, tool_name: str, kwargs: Dict[str, Any], context: Dict[str, Any]
255
+ ) -> GuardrailResult:
256
+ """Pre-call: return cached result if this is a retry."""
257
+ if tool_name not in self.tracked_tools:
258
+ return GuardrailResult.allow(self.name)
259
+ now = time.monotonic()
260
+ key = self._key(tool_name, kwargs)
261
+ with self._lock:
262
+ cached = self._cache.get(key)
263
+ if cached and now - cached[0] < self.ttl_seconds:
264
+ return GuardrailResult.replace(
265
+ cached[1],
266
+ reason=f"Returned cached result for {tool_name} (idempotency)",
267
+ guardrail_name=self.name,
268
+ )
269
+ return GuardrailResult.allow(self.name)
270
+
271
+ def check(
272
+ self, tool_name: str, kwargs: Dict[str, Any], context: Dict[str, Any]
273
+ ) -> GuardrailResult:
274
+ return self.check_pre(tool_name, kwargs, context)
275
+
276
+ def record(self, tool_name: str, kwargs: Dict[str, Any], output: Any) -> None:
277
+ """Called after a successful tool call to cache the result."""
278
+ if tool_name not in self.tracked_tools:
279
+ return
280
+ key = self._key(tool_name, kwargs)
281
+ with self._lock:
282
+ self._cache[key] = (time.monotonic(), output)
283
+
284
+
285
+ # ---------------------------------------------------------------------------
286
+ # 5. Resource quotas
287
+ # ---------------------------------------------------------------------------
288
+
289
+
290
+ @dataclass
291
+ class ResourceQuota:
292
+ """Per-session CPU time and memory caps.
293
+
294
+ Tracks cumulative CPU time and peak memory across all tool calls
295
+ in a session. Blocks new calls when the cap is reached.
296
+ """
297
+
298
+ max_cpu_seconds: Optional[float] = None
299
+ max_memory_mb: Optional[float] = None
300
+ _cpu_used: Dict[str, float] = field(default_factory=dict)
301
+ _lock: threading.Lock = field(default_factory=threading.Lock)
302
+ name: str = "resource_quota"
303
+
304
+ def check(
305
+ self, tool_name: str, kwargs: Dict[str, Any], context: Dict[str, Any]
306
+ ) -> GuardrailResult:
307
+ session_id = context.get("session_id", "default")
308
+ with self._lock:
309
+ used = self._cpu_used.get(session_id, 0.0)
310
+ if self.max_cpu_seconds is not None and used >= self.max_cpu_seconds:
311
+ return GuardrailResult.block(
312
+ f"CPU quota exceeded: {used:.2f}s / {self.max_cpu_seconds}s",
313
+ self.name,
314
+ )
315
+
316
+ if self.max_memory_mb is not None:
317
+ try:
318
+ import resource
319
+
320
+ rusage = resource.getrusage(resource.RUSAGE_SELF)
321
+ # ru_maxrss is in KB on Linux, bytes on macOS
322
+ import sys
323
+
324
+ mem_mb = rusage.ru_maxrss / (1024 if sys.platform == "linux" else 1024 * 1024)
325
+ if mem_mb > self.max_memory_mb:
326
+ return GuardrailResult.block(
327
+ f"Memory quota exceeded: {mem_mb:.1f}MB / {self.max_memory_mb}MB",
328
+ self.name,
329
+ )
330
+ except ImportError:
331
+ pass # resource module not available (Windows)
332
+ return GuardrailResult.allow(self.name)
333
+
334
+ def record_cpu(self, session_id: str, seconds: float) -> None:
335
+ with self._lock:
336
+ self._cpu_used[session_id] = self._cpu_used.get(session_id, 0.0) + seconds
337
+
338
+
339
+ # ---------------------------------------------------------------------------
340
+ # 6. Output length limits
341
+ # ---------------------------------------------------------------------------
342
+
343
+
344
+ @dataclass
345
+ class OutputLengthLimit:
346
+ """Block tool outputs that exceed a size limit.
347
+
348
+ Prevents context blowup attacks where a tool returns an enormous
349
+ payload to poison the agent's context window.
350
+ """
351
+
352
+ max_chars: int = 100_000
353
+ max_tokens: Optional[int] = None # rough estimate: chars / 4
354
+ name: str = "output_length_limit"
355
+
356
+ def check(
357
+ self,
358
+ tool_name: str,
359
+ kwargs: Dict[str, Any],
360
+ output: Any,
361
+ context: Dict[str, Any],
362
+ ) -> GuardrailResult:
363
+ text = json.dumps(output, default=str) if not isinstance(output, str) else output
364
+ if len(text) > self.max_chars:
365
+ return GuardrailResult.block(
366
+ f"Output exceeds {self.max_chars} chars ({len(text)} chars)",
367
+ self.name,
368
+ )
369
+ if self.max_tokens is not None:
370
+ est_tokens = len(text) // 4
371
+ if est_tokens > self.max_tokens:
372
+ return GuardrailResult.block(
373
+ f"Output exceeds ~{self.max_tokens} tokens (~{est_tokens})",
374
+ self.name,
375
+ )
376
+ return GuardrailResult.allow(self.name)
377
+
378
+
379
+ # ---------------------------------------------------------------------------
380
+ # 7. URL allowlist / blocklist
381
+ # ---------------------------------------------------------------------------
382
+
383
+
384
+ @dataclass
385
+ class UrlGuard:
386
+ """Allowlist or blocklist URLs/domains for tool arguments.
387
+
388
+ Scans string kwargs for URLs and checks each against the lists.
389
+ Patterns support wildcards (``*.example.com``).
390
+
391
+ Usage:
392
+ UrlGuard(
393
+ allowlist=["*.mycompany.com", "api.trusted.com"],
394
+ blocklist=["*.internal.com"],
395
+ )
396
+ """
397
+
398
+ allowlist: List[str] = field(default_factory=list)
399
+ blocklist: List[str] = field(default_factory=list)
400
+ name: str = "url_guard"
401
+
402
+ def _extract_urls(self, text: str) -> List[str]:
403
+ return re.findall(r"https?://[^\s\"'<>]+", text)
404
+
405
+ def _domain_matches(self, domain: str, patterns: List[str]) -> bool:
406
+ for pat in patterns:
407
+ if fnmatch.fnmatch(domain, pat):
408
+ return True
409
+ return False
410
+
411
+ def check(
412
+ self, tool_name: str, kwargs: Dict[str, Any], context: Dict[str, Any]
413
+ ) -> GuardrailResult:
414
+ urls: Set[str] = set()
415
+ for v in kwargs.values():
416
+ if isinstance(v, str):
417
+ urls.update(self._extract_urls(v))
418
+ # Also handle bare URLs
419
+ if v.startswith("http://") or v.startswith("https://"):
420
+ urls.add(v)
421
+
422
+ for url in urls:
423
+ try:
424
+ domain = urlparse(url).netloc.lower()
425
+ except Exception:
426
+ continue
427
+ if self.blocklist and self._domain_matches(domain, self.blocklist):
428
+ return GuardrailResult.block(f"URL blocked: {domain}", self.name)
429
+ if self.allowlist and not self._domain_matches(domain, self.allowlist):
430
+ return GuardrailResult.block(f"URL not in allowlist: {domain}", self.name)
431
+ return GuardrailResult.allow(self.name)
432
+
433
+
434
+ # ---------------------------------------------------------------------------
435
+ # 8. Active hours
436
+ # ---------------------------------------------------------------------------
437
+
438
+
439
+ @dataclass
440
+ class ActiveHours:
441
+ """Restrict when an agent can run (compliance use case).
442
+
443
+ Blocks all tool calls outside of the configured hours.
444
+ Useful for agents that should only run during business hours.
445
+
446
+ Args:
447
+ start_hour: 0-23, inclusive (UTC).
448
+ end_hour: 0-23, exclusive (UTC).
449
+ days_of_week: list of 0-6 (Mon=0). None = all days.
450
+ """
451
+
452
+ start_hour: int = 0
453
+ end_hour: int = 24
454
+ days_of_week: Optional[List[int]] = None
455
+ name: str = "active_hours"
456
+
457
+ def check(
458
+ self, tool_name: str, kwargs: Dict[str, Any], context: Dict[str, Any]
459
+ ) -> GuardrailResult:
460
+ now = datetime.now(timezone.utc)
461
+ if self.days_of_week is not None and now.weekday() not in self.days_of_week:
462
+ return GuardrailResult.block(
463
+ f"Outside active days: {now.strftime('%A')} not allowed",
464
+ self.name,
465
+ )
466
+ if not (self.start_hour <= now.hour < self.end_hour):
467
+ return GuardrailResult.block(
468
+ f"Outside active hours: {now.hour}:00 not in "
469
+ f"{self.start_hour}:00-{self.end_hour}:00 UTC",
470
+ self.name,
471
+ )
472
+ return GuardrailResult.allow(self.name)