toolboundary 0.1.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.
@@ -0,0 +1,80 @@
1
+ """
2
+ ToolBoundary
3
+ ============
4
+ Runtime boundary enforcement for AI agents -- as a library, not a service.
5
+
6
+ ToolBoundary answers one question, fast and locally, every time an agent
7
+ tries to call a tool: "is this exact call allowed, right now?" It is
8
+ designed for the common case of one to a handful of agents built by a
9
+ single team, where standing up a separate governance web application and
10
+ database would be disproportionate overhead.
11
+
12
+ Quickstart
13
+ ----------
14
+ >>> from toolboundary import Boundary, ToolPermission, AutonomyLevel, AccessMode
15
+ >>>
16
+ >>> boundary = Boundary(
17
+ ... agent_name="support-agent",
18
+ ... autonomy=AutonomyLevel.LIMITED_AUTONOMOUS,
19
+ ... permissions=[
20
+ ... ToolPermission("read_ticket_db", access_mode=AccessMode.READ_ONLY),
21
+ ... ToolPermission(
22
+ ... "send_reply_email",
23
+ ... access_mode=AccessMode.EXECUTE,
24
+ ... max_calls_per_hour=30,
25
+ ... ),
26
+ ... ],
27
+ ... blocked_operations=frozenset({"delete_ticket"}),
28
+ ... max_actions_per_hour=100,
29
+ ... kill_switch_env="TOOLBOUNDARY_KILL_SWITCH",
30
+ ... )
31
+ >>>
32
+ >>> boundary.check("read_ticket_db", access_mode=AccessMode.READ_ONLY) # passes silently
33
+ >>> boundary.check("delete_ticket", operation="delete_ticket") # raises BoundaryViolation
34
+
35
+ For a decorator-based approach, see `toolboundary.guarded_tool`.
36
+ For LangChain, see `toolboundary.integrations.langchain`.
37
+ """
38
+
39
+ from .audit import AuditEvent, AuditTrail, JSONLFileSink, LoggingSink, WebhookSink
40
+ from .boundary import Boundary, CallContext
41
+ from .decorators import guarded_tool
42
+ from .enums import AccessMode, AutonomyLevel, DecisionType, ViolationReason
43
+ from .exceptions import (
44
+ ApprovalRequired,
45
+ BoundaryViolation,
46
+ ConfigurationError,
47
+ KillSwitchActive,
48
+ RateLimitExceeded,
49
+ ToolBoundaryError,
50
+ )
51
+ from .permissions import ToolPermission
52
+
53
+ __version__ = "0.1.0"
54
+
55
+ __all__ = [
56
+ "__version__",
57
+ # core
58
+ "Boundary",
59
+ "CallContext",
60
+ "ToolPermission",
61
+ "guarded_tool",
62
+ # enums
63
+ "AccessMode",
64
+ "AutonomyLevel",
65
+ "DecisionType",
66
+ "ViolationReason",
67
+ # exceptions
68
+ "ToolBoundaryError",
69
+ "BoundaryViolation",
70
+ "KillSwitchActive",
71
+ "RateLimitExceeded",
72
+ "ApprovalRequired",
73
+ "ConfigurationError",
74
+ # audit
75
+ "AuditTrail",
76
+ "AuditEvent",
77
+ "LoggingSink",
78
+ "JSONLFileSink",
79
+ "WebhookSink",
80
+ ]
@@ -0,0 +1,62 @@
1
+ """
2
+ toolboundary._rate_limiter
3
+ --------------------------
4
+ Minimal in-memory sliding-window rate limiter.
5
+
6
+ Private module (leading underscore): this is an internal implementation
7
+ detail of Boundary, not part of the public API surface. Kept in-memory and
8
+ dependency-free on purpose -- ToolBoundary must add near-zero latency and
9
+ must not require Redis or a database to function for the common case of
10
+ a single-process agent.
11
+
12
+ For multi-process deployments, swap this out via the `store` hook in
13
+ Boundary (see boundary.py) -- e.g. backing it with Redis INCR/EXPIRE.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import threading
19
+ import time
20
+ from collections import deque
21
+
22
+
23
+ class SlidingWindowRateLimiter:
24
+ """Tracks timestamps of recent calls per key and enforces a max-per-window limit."""
25
+
26
+ def __init__(self) -> None:
27
+ self._calls: dict[str, deque[float]] = {}
28
+ self._lock = threading.Lock()
29
+
30
+ def check_and_record(self, key: str, max_calls: int, window_seconds: float) -> bool:
31
+ """
32
+ Returns True if the call is allowed (and records it).
33
+ Returns False if the call would exceed max_calls within window_seconds.
34
+ """
35
+ now = time.monotonic()
36
+ with self._lock:
37
+ window = self._calls.setdefault(key, deque())
38
+
39
+ # Drop timestamps outside the window
40
+ cutoff = now - window_seconds
41
+ while window and window[0] < cutoff:
42
+ window.popleft()
43
+
44
+ if len(window) >= max_calls:
45
+ return False
46
+
47
+ window.append(now)
48
+ return True
49
+
50
+ def current_count(self, key: str, window_seconds: float) -> int:
51
+ now = time.monotonic()
52
+ with self._lock:
53
+ window = self._calls.get(key, deque())
54
+ cutoff = now - window_seconds
55
+ return sum(1 for t in window if t >= cutoff)
56
+
57
+ def reset(self, key: str | None = None) -> None:
58
+ with self._lock:
59
+ if key is None:
60
+ self._calls.clear()
61
+ else:
62
+ self._calls.pop(key, None)
toolboundary/audit.py ADDED
@@ -0,0 +1,169 @@
1
+ """
2
+ toolboundary.audit
3
+ ------------------
4
+ Local-first audit trail.
5
+
6
+ Design goal: ToolBoundary must never require a hosted service or database to
7
+ produce useful audit evidence. By default every decision is emitted as a
8
+ structured JSON line to stdlib `logging` (logger name "toolboundary.audit"),
9
+ which the host application can route anywhere logging already goes
10
+ (stdout, a file, Datadog, CloudWatch, etc.) with zero extra code.
11
+
12
+ Optional sinks (JSONL file, HTTP webhook) are provided for teams that want
13
+ a durable local record or want to forward events to a centralized
14
+ governance platform (e.g. a GuardianIQ-style registry) without coupling
15
+ ToolBoundary itself to any specific vendor.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import logging
22
+ import time
23
+ import uuid
24
+ from dataclasses import asdict, dataclass, field
25
+ from pathlib import Path
26
+ from typing import Any, Protocol
27
+
28
+ _logger = logging.getLogger("toolboundary.audit")
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class AuditEvent:
33
+ """
34
+ One record of a single ToolBoundary decision.
35
+
36
+ Field names intentionally echo the vocabulary used by enterprise AI
37
+ governance registries (agent_id, tool_id, access_mode, decision) so
38
+ that events can be reconciled with, or imported into, such a system
39
+ later without a translation layer.
40
+ """
41
+
42
+ event_id: str
43
+ timestamp: float
44
+ agent_name: str
45
+ tool_name: str | None
46
+ operation: str | None
47
+ access_mode: str | None
48
+ decision: str # ALLOW | DENY | APPROVAL_REQUIRED
49
+ reason_code: str | None
50
+ message: str
51
+ correlation_id: str | None = None
52
+ metadata: dict[str, Any] = field(default_factory=dict)
53
+
54
+ def to_dict(self) -> dict[str, Any]:
55
+ return asdict(self)
56
+
57
+ def to_json(self) -> str:
58
+ return json.dumps(self.to_dict(), default=str, sort_keys=True)
59
+
60
+
61
+ class AuditSink(Protocol):
62
+ """Anything with an `emit(event)` method can be used as a sink."""
63
+
64
+ def emit(self, event: AuditEvent) -> None: ...
65
+
66
+
67
+ class LoggingSink:
68
+ """Default sink: writes structured JSON to Python's stdlib logging."""
69
+
70
+ def __init__(self, level: int = logging.INFO) -> None:
71
+ self._level = level
72
+
73
+ def emit(self, event: AuditEvent) -> None:
74
+ level = self._level if event.decision == "ALLOW" else logging.WARNING
75
+ _logger.log(level, event.to_json())
76
+
77
+
78
+ class JSONLFileSink:
79
+ """Appends each event as one JSON line to a local file. Thread-safe append."""
80
+
81
+ def __init__(self, path: str | Path) -> None:
82
+ self._path = Path(path)
83
+ self._path.parent.mkdir(parents=True, exist_ok=True)
84
+
85
+ def emit(self, event: AuditEvent) -> None:
86
+ with self._path.open("a", encoding="utf-8") as f:
87
+ f.write(event.to_json())
88
+ f.write("\n")
89
+
90
+
91
+ class WebhookSink:
92
+ """
93
+ Forwards events to an HTTP endpoint (e.g. a self-hosted dashboard or a
94
+ governance platform's ingestion API). Uses `urllib` to avoid forcing a
95
+ `requests` dependency on users who don't need this sink.
96
+
97
+ Failures are swallowed (never raised) so that a network hiccup in your
98
+ audit pipeline can never block or crash the agent itself -- audit
99
+ delivery is best-effort by design; the ALLOW/DENY decision has already
100
+ been enforced locally before this sink is even invoked.
101
+ """
102
+
103
+ def __init__(
104
+ self, url: str, timeout: float = 2.0, headers: dict[str, str] | None = None
105
+ ) -> None:
106
+ self._url = url
107
+ self._timeout = timeout
108
+ self._headers = headers or {"Content-Type": "application/json"}
109
+
110
+ def emit(self, event: AuditEvent) -> None:
111
+ import urllib.request
112
+
113
+ try:
114
+ data = event.to_json().encode("utf-8")
115
+ # S310: the webhook URL is supplied by the host application at
116
+ # WebhookSink construction time, not by untrusted input reaching
117
+ # this code path -- this is a deliberate, documented HTTP POST,
118
+ # not an arbitrary-scheme file open.
119
+ req = urllib.request.Request( # noqa: S310
120
+ self._url, data=data, headers=self._headers, method="POST"
121
+ )
122
+ urllib.request.urlopen(req, timeout=self._timeout) # noqa: S310
123
+ except Exception: # noqa: BLE001 - audit delivery must never raise
124
+ _logger.debug("toolboundary: webhook audit sink failed to deliver event", exc_info=True)
125
+
126
+
127
+ class AuditTrail:
128
+ """Fans out one AuditEvent to any number of configured sinks."""
129
+
130
+ def __init__(self, sinks: list[AuditSink] | None = None) -> None:
131
+ self.sinks: list[AuditSink] = sinks if sinks is not None else [LoggingSink()]
132
+
133
+ def add_sink(self, sink: AuditSink) -> None:
134
+ self.sinks.append(sink)
135
+
136
+ def record(
137
+ self,
138
+ *,
139
+ agent_name: str,
140
+ decision: str,
141
+ message: str,
142
+ tool_name: str | None = None,
143
+ operation: str | None = None,
144
+ access_mode: str | None = None,
145
+ reason_code: str | None = None,
146
+ correlation_id: str | None = None,
147
+ metadata: dict[str, Any] | None = None,
148
+ ) -> AuditEvent:
149
+ event = AuditEvent(
150
+ event_id=str(uuid.uuid4()),
151
+ timestamp=time.time(),
152
+ agent_name=agent_name,
153
+ tool_name=tool_name,
154
+ operation=operation,
155
+ access_mode=access_mode,
156
+ decision=decision,
157
+ reason_code=reason_code,
158
+ message=message,
159
+ correlation_id=correlation_id,
160
+ metadata=metadata or {},
161
+ )
162
+ for sink in self.sinks:
163
+ try:
164
+ sink.emit(event)
165
+ except Exception: # noqa: BLE001
166
+ _logger.debug(
167
+ "toolboundary: sink %r raised while emitting event", sink, exc_info=True
168
+ )
169
+ return event