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.
- agenthandler/__init__.py +163 -0
- agenthandler/approval.py +135 -0
- agenthandler/audit.py +144 -0
- agenthandler/auth.py +188 -0
- agenthandler/budget.py +94 -0
- agenthandler/builder.py +178 -0
- agenthandler/circuit_breaker.py +88 -0
- agenthandler/connectors.py +425 -0
- agenthandler/context.py +410 -0
- agenthandler/errors.py +130 -0
- agenthandler/guardrails/__init__.py +79 -0
- agenthandler/guardrails/deterministic.py +472 -0
- agenthandler/guardrails/llm_based.py +549 -0
- agenthandler/guardrails/types.py +83 -0
- agenthandler/observe.py +525 -0
- agenthandler/openclaw.py +370 -0
- agenthandler/pipeline.py +532 -0
- agenthandler/pipeline_poml.py +593 -0
- agenthandler/policy.py +251 -0
- agenthandler/py.typed +0 -0
- agenthandler/redact.py +260 -0
- agenthandler/reflection.py +371 -0
- agenthandler/replay.py +225 -0
- agenthandler/server.py +752 -0
- agenthandler/session.py +391 -0
- agenthandler/skill.py +265 -0
- agenthandler/store.py +341 -0
- agenthandler/streams.py +339 -0
- agenthandler/supervisor.py +882 -0
- agenthandler/triggers.py +471 -0
- agenthandler-0.92.0.dist-info/METADATA +1355 -0
- agenthandler-0.92.0.dist-info/RECORD +34 -0
- agenthandler-0.92.0.dist-info/WHEEL +4 -0
- agenthandler-0.92.0.dist-info/licenses/LICENSE +201 -0
|
@@ -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)
|