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.
- toolboundary/__init__.py +80 -0
- toolboundary/_rate_limiter.py +62 -0
- toolboundary/audit.py +169 -0
- toolboundary/boundary.py +505 -0
- toolboundary/decorators.py +117 -0
- toolboundary/enums.py +58 -0
- toolboundary/exceptions.py +88 -0
- toolboundary/integrations/__init__.py +8 -0
- toolboundary/integrations/langchain.py +139 -0
- toolboundary/network.py +275 -0
- toolboundary/permissions.py +71 -0
- toolboundary/tokens.py +215 -0
- toolboundary-0.1.0.dist-info/METADATA +252 -0
- toolboundary-0.1.0.dist-info/RECORD +16 -0
- toolboundary-0.1.0.dist-info/WHEEL +4 -0
- toolboundary-0.1.0.dist-info/licenses/LICENSE +21 -0
toolboundary/__init__.py
ADDED
|
@@ -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
|