tollgate 1.0.4__py3-none-any.whl → 1.4.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.
tollgate/__init__.py CHANGED
@@ -1,3 +1,4 @@
1
+ from .anomaly_detector import AnomalyAlert, AnomalyDetector
1
2
  from .approvals import (
2
3
  ApprovalStore,
3
4
  Approver,
@@ -7,15 +8,24 @@ from .approvals import (
7
8
  InMemoryApprovalStore,
8
9
  compute_request_hash,
9
10
  )
10
- from .audit import AuditSink, JsonlAuditSink
11
+ from .audit import AuditSink, CompositeAuditSink, JsonlAuditSink, WebhookAuditSink
12
+ from .circuit_breaker import CircuitBreaker, CircuitState, InMemoryCircuitBreaker
13
+ from .context_monitor import ContextIntegrityMonitor, VerificationResult
11
14
  from .exceptions import (
12
15
  TollgateApprovalDenied,
16
+ TollgateConstraintViolation,
13
17
  TollgateDeferred,
14
18
  TollgateDenied,
15
19
  TollgateError,
20
+ TollgateRateLimited,
16
21
  )
17
- from .grants import InMemoryGrantStore
22
+ from .grants import GrantStore, InMemoryGrantStore
18
23
  from .helpers import guard, wrap_tool
24
+ from .manifest_signing import sign_manifest, verify_manifest, get_manifest_hash
25
+ from .network_guard import NetworkGuard
26
+ from .policy_testing import PolicyTestRunner, PolicyTestRunResult
27
+ from .rate_limiter import InMemoryRateLimiter, RateLimiter
28
+ from .verification import make_verifier, sign_agent_context, verify_agent_context
19
29
  from .policy import PolicyEvaluator, YamlPolicyEvaluator
20
30
  from .registry import ToolRegistry
21
31
  from .tower import ControlTower
@@ -33,7 +43,7 @@ from .types import (
33
43
  ToolRequest,
34
44
  )
35
45
 
36
- __version__ = "1.0.4"
46
+ __version__ = "1.4.0"
37
47
 
38
48
  __all__ = [
39
49
  "ControlTower",
@@ -45,6 +55,7 @@ __all__ = [
45
55
  "DecisionType",
46
56
  "Effect",
47
57
  "Grant",
58
+ "GrantStore",
48
59
  "AuditEvent",
49
60
  "Outcome",
50
61
  "ApprovalOutcome",
@@ -57,6 +68,8 @@ __all__ = [
57
68
  "compute_request_hash",
58
69
  "AuditSink",
59
70
  "JsonlAuditSink",
71
+ "CompositeAuditSink",
72
+ "WebhookAuditSink",
60
73
  "ToolRegistry",
61
74
  "PolicyEvaluator",
62
75
  "YamlPolicyEvaluator",
@@ -65,6 +78,26 @@ __all__ = [
65
78
  "TollgateDenied",
66
79
  "TollgateApprovalDenied",
67
80
  "TollgateDeferred",
81
+ "TollgateRateLimited",
82
+ "TollgateConstraintViolation",
83
+ "RateLimiter",
84
+ "InMemoryRateLimiter",
85
+ "CircuitBreaker",
86
+ "InMemoryCircuitBreaker",
87
+ "CircuitState",
88
+ "NetworkGuard",
89
+ "sign_manifest",
90
+ "verify_manifest",
91
+ "get_manifest_hash",
92
+ "sign_agent_context",
93
+ "verify_agent_context",
94
+ "make_verifier",
95
+ "PolicyTestRunner",
96
+ "PolicyTestRunResult",
97
+ "ContextIntegrityMonitor",
98
+ "VerificationResult",
99
+ "AnomalyDetector",
100
+ "AnomalyAlert",
68
101
  "wrap_tool",
69
102
  "guard",
70
103
  ]
@@ -0,0 +1,396 @@
1
+ """Anomaly detection on Tollgate audit event streams.
2
+
3
+ Consumes audit events and computes baseline call frequency per
4
+ (agent, tool) pair. Detects anomalies using z-score deviation on
5
+ sliding windows.
6
+
7
+ This is a complementary layer that plugs into Tollgate's AuditSink
8
+ protocol. It can operate as both a real-time detector (via the AuditSink
9
+ interface) and a batch analyzer (via ``analyze()``).
10
+
11
+ Usage:
12
+
13
+ from tollgate.anomaly_detector import AnomalyDetector
14
+
15
+ detector = AnomalyDetector(
16
+ window_seconds=300, # 5-minute sliding window
17
+ z_score_threshold=3.0, # Alert at 3 standard deviations
18
+ min_samples=10, # Need 10+ data points before alerting
19
+ )
20
+
21
+ # Use as an AuditSink (real-time)
22
+ composite = CompositeAuditSink([jsonl_sink, detector])
23
+ tower = ControlTower(..., audit=composite)
24
+
25
+ # Or analyze batch data
26
+ alerts = detector.analyze()
27
+ """
28
+
29
+ import logging
30
+ import math
31
+ import time
32
+ from collections import defaultdict
33
+ from dataclasses import dataclass, field
34
+ from typing import Any
35
+
36
+ from .types import AuditEvent, Outcome
37
+
38
+ logger = logging.getLogger("tollgate.anomaly_detector")
39
+
40
+
41
+ @dataclass
42
+ class AnomalyAlert:
43
+ """An anomaly alert generated by the detector."""
44
+
45
+ alert_type: str # "rate_spike", "error_burst", "unusual_tool", "deny_surge"
46
+ agent_id: str
47
+ tool: str | None
48
+ severity: str # "low", "medium", "high", "critical"
49
+ message: str
50
+ z_score: float | None = None
51
+ current_rate: float = 0.0
52
+ baseline_rate: float = 0.0
53
+ timestamp: float = 0.0
54
+
55
+ def to_dict(self) -> dict[str, Any]:
56
+ return {
57
+ "alert_type": self.alert_type,
58
+ "agent_id": self.agent_id,
59
+ "tool": self.tool,
60
+ "severity": self.severity,
61
+ "message": self.message,
62
+ "z_score": self.z_score,
63
+ "current_rate": self.current_rate,
64
+ "baseline_rate": self.baseline_rate,
65
+ "timestamp": self.timestamp,
66
+ }
67
+
68
+
69
+ class _SlidingWindow:
70
+ """A time-based sliding window counter."""
71
+
72
+ __slots__ = ("_timestamps", "_window_seconds")
73
+
74
+ def __init__(self, window_seconds: float):
75
+ self._timestamps: list[float] = []
76
+ self._window_seconds = window_seconds
77
+
78
+ def add(self, ts: float | None = None):
79
+ self._timestamps.append(ts or time.time())
80
+
81
+ def _prune(self, now: float):
82
+ cutoff = now - self._window_seconds
83
+ # Binary search would be faster but list is typically small
84
+ self._timestamps = [t for t in self._timestamps if t > cutoff]
85
+
86
+ def count(self, now: float | None = None) -> int:
87
+ now = now or time.time()
88
+ self._prune(now)
89
+ return len(self._timestamps)
90
+
91
+ def rate(self, now: float | None = None) -> float:
92
+ """Events per second in the current window."""
93
+ now = now or time.time()
94
+ self._prune(now)
95
+ if not self._timestamps:
96
+ return 0.0
97
+ return len(self._timestamps) / self._window_seconds
98
+
99
+
100
+ class _BaselineTracker:
101
+ """Tracks historical rates to compute mean and standard deviation."""
102
+
103
+ def __init__(self, max_samples: int = 100):
104
+ self._samples: list[float] = []
105
+ self._max_samples = max_samples
106
+
107
+ def add_sample(self, rate: float):
108
+ self._samples.append(rate)
109
+ if len(self._samples) > self._max_samples:
110
+ self._samples.pop(0)
111
+
112
+ @property
113
+ def count(self) -> int:
114
+ return len(self._samples)
115
+
116
+ @property
117
+ def mean(self) -> float:
118
+ if not self._samples:
119
+ return 0.0
120
+ return sum(self._samples) / len(self._samples)
121
+
122
+ @property
123
+ def std_dev(self) -> float:
124
+ if len(self._samples) < 2:
125
+ return 0.0
126
+ m = self.mean
127
+ variance = sum((x - m) ** 2 for x in self._samples) / (len(self._samples) - 1)
128
+ return math.sqrt(variance)
129
+
130
+ def z_score(self, value: float) -> float | None:
131
+ """Compute z-score for a value. Returns None if insufficient data."""
132
+ if self.count < 2 or self.std_dev == 0:
133
+ return None
134
+ return (value - self.mean) / self.std_dev
135
+
136
+
137
+ class AnomalyDetector:
138
+ """Real-time anomaly detection on Tollgate audit streams.
139
+
140
+ Implements the AuditSink protocol so it can be used directly with
141
+ CompositeAuditSink. Detects:
142
+
143
+ - **Rate spikes**: Unusual call frequency per (agent, tool)
144
+ - **Error bursts**: Sudden increase in FAILED outcomes
145
+ - **Deny surges**: Unusual number of BLOCKED/DENIED outcomes
146
+ - **Unusual tools**: Agent calling tools they haven't used before
147
+
148
+ Args:
149
+ window_seconds: Sliding window duration for rate calculation.
150
+ z_score_threshold: Number of standard deviations to trigger an alert.
151
+ min_samples: Minimum baseline samples before alerting.
152
+ alert_callback: Optional callback invoked for each alert.
153
+ baseline_interval: Seconds between baseline rate samples.
154
+ """
155
+
156
+ def __init__(
157
+ self,
158
+ *,
159
+ window_seconds: float = 300.0,
160
+ z_score_threshold: float = 3.0,
161
+ min_samples: int = 10,
162
+ alert_callback: Any | None = None,
163
+ baseline_interval: float = 60.0,
164
+ ):
165
+ self._window_seconds = window_seconds
166
+ self._z_threshold = z_score_threshold
167
+ self._min_samples = min_samples
168
+ self._alert_callback = alert_callback
169
+ self._baseline_interval = baseline_interval
170
+
171
+ # Per (agent, tool) sliding windows
172
+ self._call_windows: dict[str, _SlidingWindow] = defaultdict(
173
+ lambda: _SlidingWindow(window_seconds)
174
+ )
175
+ # Per agent error windows
176
+ self._error_windows: dict[str, _SlidingWindow] = defaultdict(
177
+ lambda: _SlidingWindow(window_seconds)
178
+ )
179
+ # Per agent deny windows
180
+ self._deny_windows: dict[str, _SlidingWindow] = defaultdict(
181
+ lambda: _SlidingWindow(window_seconds)
182
+ )
183
+
184
+ # Baseline trackers
185
+ self._call_baselines: dict[str, _BaselineTracker] = defaultdict(
186
+ _BaselineTracker
187
+ )
188
+ self._error_baselines: dict[str, _BaselineTracker] = defaultdict(
189
+ _BaselineTracker
190
+ )
191
+ self._deny_baselines: dict[str, _BaselineTracker] = defaultdict(
192
+ _BaselineTracker
193
+ )
194
+
195
+ # Track known tool usage per agent
196
+ self._known_tools: dict[str, set[str]] = defaultdict(set)
197
+
198
+ # Last baseline sample timestamp
199
+ self._last_baseline_sample: float = 0.0
200
+
201
+ # Accumulated alerts
202
+ self._alerts: list[AnomalyAlert] = []
203
+
204
+ def _key(self, agent_id: str, tool: str) -> str:
205
+ return f"{agent_id}:{tool}"
206
+
207
+ def emit(self, event: AuditEvent):
208
+ """AuditSink protocol — process an audit event."""
209
+ now = time.time()
210
+ agent_id = event.agent.agent_id
211
+ tool = event.tool_request.tool
212
+ key = self._key(agent_id, tool)
213
+
214
+ # Record in sliding windows
215
+ self._call_windows[key].add(now)
216
+
217
+ if event.outcome == Outcome.FAILED:
218
+ self._error_windows[agent_id].add(now)
219
+
220
+ if event.outcome in (Outcome.BLOCKED, Outcome.APPROVAL_DENIED):
221
+ self._deny_windows[agent_id].add(now)
222
+
223
+ # Check for unusual tool usage
224
+ if tool not in self._known_tools[agent_id]:
225
+ if self._known_tools[agent_id]: # Has history
226
+ alert = AnomalyAlert(
227
+ alert_type="unusual_tool",
228
+ agent_id=agent_id,
229
+ tool=tool,
230
+ severity="medium",
231
+ message=(
232
+ f"Agent '{agent_id}' called tool '{tool}' for the first "
233
+ f"time (known tools: {sorted(self._known_tools[agent_id])})"
234
+ ),
235
+ timestamp=now,
236
+ )
237
+ self._fire_alert(alert)
238
+ self._known_tools[agent_id].add(tool)
239
+
240
+ # Periodically sample baselines
241
+ if now - self._last_baseline_sample >= self._baseline_interval:
242
+ self._sample_baselines(now)
243
+ self._last_baseline_sample = now
244
+
245
+ # Check for anomalies
246
+ self._check_rate_anomaly(agent_id, tool, now)
247
+ self._check_error_anomaly(agent_id, now)
248
+ self._check_deny_anomaly(agent_id, now)
249
+
250
+ def _sample_baselines(self, now: float):
251
+ """Sample current rates into baseline trackers."""
252
+ for key, window in self._call_windows.items():
253
+ rate = window.rate(now)
254
+ self._call_baselines[key].add_sample(rate)
255
+
256
+ for agent_id, window in self._error_windows.items():
257
+ rate = window.rate(now)
258
+ self._error_baselines[agent_id].add_sample(rate)
259
+
260
+ for agent_id, window in self._deny_windows.items():
261
+ rate = window.rate(now)
262
+ self._deny_baselines[agent_id].add_sample(rate)
263
+
264
+ def _check_rate_anomaly(self, agent_id: str, tool: str, now: float):
265
+ key = self._key(agent_id, tool)
266
+ baseline = self._call_baselines[key]
267
+
268
+ if baseline.count < self._min_samples:
269
+ return
270
+
271
+ current_rate = self._call_windows[key].rate(now)
272
+ z = baseline.z_score(current_rate)
273
+
274
+ if z is not None and z > self._z_threshold:
275
+ severity = "high" if z > self._z_threshold * 2 else "medium"
276
+ alert = AnomalyAlert(
277
+ alert_type="rate_spike",
278
+ agent_id=agent_id,
279
+ tool=tool,
280
+ severity=severity,
281
+ message=(
282
+ f"Rate spike for {agent_id}/{tool}: "
283
+ f"{current_rate:.2f}/s (baseline: {baseline.mean:.2f}/s, "
284
+ f"z-score: {z:.1f})"
285
+ ),
286
+ z_score=z,
287
+ current_rate=current_rate,
288
+ baseline_rate=baseline.mean,
289
+ timestamp=now,
290
+ )
291
+ self._fire_alert(alert)
292
+
293
+ def _check_error_anomaly(self, agent_id: str, now: float):
294
+ baseline = self._error_baselines[agent_id]
295
+
296
+ if baseline.count < self._min_samples:
297
+ return
298
+
299
+ current_rate = self._error_windows[agent_id].rate(now)
300
+ z = baseline.z_score(current_rate)
301
+
302
+ if z is not None and z > self._z_threshold:
303
+ severity = "high" if z > self._z_threshold * 2 else "medium"
304
+ alert = AnomalyAlert(
305
+ alert_type="error_burst",
306
+ agent_id=agent_id,
307
+ tool=None,
308
+ severity=severity,
309
+ message=(
310
+ f"Error burst for {agent_id}: "
311
+ f"{current_rate:.2f}/s (baseline: {baseline.mean:.2f}/s, "
312
+ f"z-score: {z:.1f})"
313
+ ),
314
+ z_score=z,
315
+ current_rate=current_rate,
316
+ baseline_rate=baseline.mean,
317
+ timestamp=now,
318
+ )
319
+ self._fire_alert(alert)
320
+
321
+ def _check_deny_anomaly(self, agent_id: str, now: float):
322
+ baseline = self._deny_baselines[agent_id]
323
+
324
+ if baseline.count < self._min_samples:
325
+ return
326
+
327
+ current_rate = self._deny_windows[agent_id].rate(now)
328
+ z = baseline.z_score(current_rate)
329
+
330
+ if z is not None and z > self._z_threshold:
331
+ severity = "critical" if z > self._z_threshold * 2 else "high"
332
+ alert = AnomalyAlert(
333
+ alert_type="deny_surge",
334
+ agent_id=agent_id,
335
+ tool=None,
336
+ severity=severity,
337
+ message=(
338
+ f"Deny surge for {agent_id}: "
339
+ f"{current_rate:.2f}/s (baseline: {baseline.mean:.2f}/s, "
340
+ f"z-score: {z:.1f})"
341
+ ),
342
+ z_score=z,
343
+ current_rate=current_rate,
344
+ baseline_rate=baseline.mean,
345
+ timestamp=now,
346
+ )
347
+ self._fire_alert(alert)
348
+
349
+ def _fire_alert(self, alert: AnomalyAlert):
350
+ """Record and dispatch an alert."""
351
+ self._alerts.append(alert)
352
+ logger.warning("Anomaly detected: %s", alert.message)
353
+
354
+ if self._alert_callback is not None:
355
+ try:
356
+ self._alert_callback(alert)
357
+ except Exception:
358
+ logger.exception("Alert callback failed")
359
+
360
+ def get_alerts(self, since: float | None = None) -> list[AnomalyAlert]:
361
+ """Get accumulated alerts, optionally filtered by timestamp."""
362
+ if since is None:
363
+ return list(self._alerts)
364
+ return [a for a in self._alerts if a.timestamp >= since]
365
+
366
+ def get_stats(self) -> dict[str, Any]:
367
+ """Get current detector statistics for monitoring."""
368
+ now = time.time()
369
+ stats: dict[str, Any] = {
370
+ "total_alerts": len(self._alerts),
371
+ "agents_tracked": len(self._known_tools),
372
+ "tools_per_agent": {
373
+ agent: len(tools) for agent, tools in self._known_tools.items()
374
+ },
375
+ "current_rates": {},
376
+ }
377
+
378
+ for key, window in self._call_windows.items():
379
+ stats["current_rates"][key] = {
380
+ "rate": window.rate(now),
381
+ "count": window.count(now),
382
+ }
383
+
384
+ return stats
385
+
386
+ def clear(self):
387
+ """Reset all state."""
388
+ self._call_windows.clear()
389
+ self._error_windows.clear()
390
+ self._deny_windows.clear()
391
+ self._call_baselines.clear()
392
+ self._error_baselines.clear()
393
+ self._deny_baselines.clear()
394
+ self._known_tools.clear()
395
+ self._alerts.clear()
396
+ self._last_baseline_sample = 0.0
tollgate/audit.py CHANGED
@@ -1,8 +1,11 @@
1
1
  import json
2
+ import logging
3
+ import threading
2
4
  from pathlib import Path
3
5
  from typing import Protocol
6
+ from urllib.request import Request, urlopen
4
7
 
5
- from .types import AuditEvent
8
+ from .types import AuditEvent, Outcome
6
9
 
7
10
 
8
11
  class AuditSink(Protocol):
@@ -45,3 +48,89 @@ class JsonlAuditSink:
45
48
 
46
49
  def __del__(self):
47
50
  self.close()
51
+
52
+
53
+ class CompositeAuditSink:
54
+ """Chains multiple AuditSink implementations together.
55
+
56
+ Example:
57
+ sink = CompositeAuditSink([
58
+ JsonlAuditSink("audit.jsonl"),
59
+ WebhookAuditSink("https://hooks.slack.com/..."),
60
+ ])
61
+ """
62
+
63
+ def __init__(self, sinks: list[AuditSink]):
64
+ if not sinks:
65
+ raise ValueError("CompositeAuditSink requires at least one sink.")
66
+ self._sinks = list(sinks)
67
+
68
+ def emit(self, event: AuditEvent) -> None:
69
+ """Emit an event to all registered sinks."""
70
+ for sink in self._sinks:
71
+ try:
72
+ sink.emit(event)
73
+ except Exception:
74
+ # Never let one sink failure block others
75
+ logging.getLogger("tollgate.audit").exception(
76
+ "AuditSink failed: %s", type(sink).__name__
77
+ )
78
+
79
+
80
+ class WebhookAuditSink:
81
+ """Fire-and-forget HTTP POST on security-relevant audit events.
82
+
83
+ By default, alerts are sent for BLOCKED, APPROVAL_DENIED, FAILED,
84
+ and TIMEOUT outcomes. Customise via ``alert_outcomes``.
85
+
86
+ The webhook payload is the full AuditEvent dict as JSON. Requests are
87
+ dispatched on a daemon thread so they never block the execution path.
88
+ """
89
+
90
+ # Outcomes that trigger a webhook by default
91
+ DEFAULT_ALERT_OUTCOMES = frozenset(
92
+ {Outcome.BLOCKED, Outcome.APPROVAL_DENIED, Outcome.FAILED, Outcome.TIMEOUT}
93
+ )
94
+
95
+ def __init__(
96
+ self,
97
+ webhook_url: str,
98
+ *,
99
+ alert_outcomes: frozenset[Outcome] | None = None,
100
+ timeout_seconds: float = 5.0,
101
+ headers: dict[str, str] | None = None,
102
+ ):
103
+ if not webhook_url:
104
+ raise ValueError("webhook_url must not be empty.")
105
+ self.webhook_url = webhook_url
106
+ self.alert_outcomes = alert_outcomes or self.DEFAULT_ALERT_OUTCOMES
107
+ self.timeout_seconds = timeout_seconds
108
+ self._headers = {"Content-Type": "application/json"}
109
+ if headers:
110
+ self._headers.update(headers)
111
+ self._logger = logging.getLogger("tollgate.audit.webhook")
112
+
113
+ def emit(self, event: AuditEvent) -> None:
114
+ """Send a webhook if the event outcome warrants an alert."""
115
+ if event.outcome not in self.alert_outcomes:
116
+ return # Not an alertable event — skip silently
117
+
118
+ # Fire-and-forget on a daemon thread to avoid blocking execution
119
+ thread = threading.Thread(
120
+ target=self._send, args=(event,), daemon=True
121
+ )
122
+ thread.start()
123
+
124
+ def _send(self, event: AuditEvent) -> None:
125
+ """Synchronous HTTP POST (runs on background thread)."""
126
+ try:
127
+ body = json.dumps(event.to_dict(), ensure_ascii=False).encode("utf-8")
128
+ req = Request(
129
+ self.webhook_url,
130
+ data=body,
131
+ headers=self._headers,
132
+ method="POST",
133
+ )
134
+ urlopen(req, timeout=self.timeout_seconds) # noqa: S310
135
+ except Exception:
136
+ self._logger.exception("Webhook delivery failed: %s", self.webhook_url)
@@ -0,0 +1,37 @@
1
+ """Persistent backends for Tollgate stores.
2
+
3
+ Provides drop-in replacements for InMemoryGrantStore and InMemoryApprovalStore
4
+ with persistent storage:
5
+
6
+ - SQLiteGrantStore / SQLiteApprovalStore — zero extra dependencies
7
+ - RedisGrantStore / RedisApprovalStore — requires ``redis[hiredis]``
8
+
9
+ Usage:
10
+
11
+ # SQLite (zero deps, good for single-process)
12
+ from tollgate.backends import SQLiteGrantStore, SQLiteApprovalStore
13
+
14
+ grant_store = SQLiteGrantStore("tollgate_grants.db")
15
+ approval_store = SQLiteApprovalStore("tollgate_approvals.db")
16
+
17
+ # Redis (multi-process, multi-host)
18
+ from tollgate.backends import RedisGrantStore, RedisApprovalStore
19
+
20
+ grant_store = RedisGrantStore(redis_url="redis://localhost:6379/0")
21
+ approval_store = RedisApprovalStore(redis_url="redis://localhost:6379/0")
22
+ """
23
+
24
+ from .sqlite_store import SQLiteApprovalStore, SQLiteGrantStore
25
+
26
+ __all__ = [
27
+ "SQLiteGrantStore",
28
+ "SQLiteApprovalStore",
29
+ ]
30
+
31
+ # Conditionally export Redis backends if redis is available
32
+ try:
33
+ from .redis_store import RedisApprovalStore, RedisGrantStore
34
+
35
+ __all__ += ["RedisGrantStore", "RedisApprovalStore"]
36
+ except ImportError:
37
+ pass