toolbound 0.2.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.
- toolbound/__init__.py +123 -0
- toolbound/adapters/__init__.py +9 -0
- toolbound/adapters/_common.py +84 -0
- toolbound/adapters/anthropic.py +81 -0
- toolbound/adapters/openai.py +115 -0
- toolbound/cli.py +69 -0
- toolbound/core/__init__.py +4 -0
- toolbound/core/approval.py +314 -0
- toolbound/core/budget.py +42 -0
- toolbound/core/context.py +56 -0
- toolbound/core/contracts.py +689 -0
- toolbound/core/credentials.py +41 -0
- toolbound/core/errors.py +117 -0
- toolbound/core/exposure.py +68 -0
- toolbound/core/gateway.py +1461 -0
- toolbound/core/network.py +267 -0
- toolbound/core/observe.py +60 -0
- toolbound/core/paths.py +138 -0
- toolbound/core/ports.py +166 -0
- toolbound/core/pyschema.py +119 -0
- toolbound/core/ratelimit.py +65 -0
- toolbound/core/result.py +100 -0
- toolbound/core/schema.py +201 -0
- toolbound/core/shaping.py +711 -0
- toolbound/core/signing.py +137 -0
- toolbound/core/state.py +102 -0
- toolbound/estimators.py +24 -0
- toolbound/loaders/__init__.py +202 -0
- toolbound/loaders/meta_schema.py +162 -0
- toolbound/py.typed +0 -0
- toolbound/sync.py +76 -0
- toolbound/transport/__init__.py +19 -0
- toolbound/transport/http.py +269 -0
- toolbound/transport/mock.py +89 -0
- toolbound-0.2.0.dist-info/METADATA +547 -0
- toolbound-0.2.0.dist-info/RECORD +39 -0
- toolbound-0.2.0.dist-info/WHEEL +4 -0
- toolbound-0.2.0.dist-info/entry_points.txt +2 -0
- toolbound-0.2.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Network policy: URL construction and target checks (SSRF defences).
|
|
2
|
+
|
|
3
|
+
This module is pure: it never performs I/O. URL-level checks happen here for
|
|
4
|
+
every request. IP-level checks (after DNS resolution) use :func:`check_ip` and
|
|
5
|
+
are performed by the transport, because only the transport knows which address
|
|
6
|
+
it actually connects to. The default :class:`~toolbound.SecureTransport`
|
|
7
|
+
resolves the host, checks **every** address, and connects to the checked
|
|
8
|
+
address, which defeats DNS rebinding between check and connect.
|
|
9
|
+
|
|
10
|
+
Residual risk is documented in ``docs/security.md``: a library cannot see
|
|
11
|
+
sidecar proxies, service meshes, or an upstream API that itself fetches URLs.
|
|
12
|
+
Run agents with egress firewall rules when they sit next to sensitive networks.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import ipaddress
|
|
18
|
+
import re
|
|
19
|
+
from collections.abc import Mapping, Sequence
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from functools import lru_cache
|
|
22
|
+
from typing import Any
|
|
23
|
+
from urllib.parse import quote, urlencode, urlsplit
|
|
24
|
+
|
|
25
|
+
from .contracts import NetworkPolicy, host_allowed
|
|
26
|
+
from .errors import ErrorCode, NetworkBlocked, ToolFailure
|
|
27
|
+
|
|
28
|
+
IPAddress = ipaddress.IPv4Address | ipaddress.IPv6Address
|
|
29
|
+
|
|
30
|
+
_HOST_CHARS = re.compile(r"^[a-z0-9.-]+$")
|
|
31
|
+
_CONTROL = re.compile(r"[\x00-\x1f\x7f]")
|
|
32
|
+
_NAT64 = ipaddress.ip_network("64:ff9b::/96")
|
|
33
|
+
_NAT64_LOCAL = ipaddress.ip_network("64:ff9b:1::/48")
|
|
34
|
+
|
|
35
|
+
#: IANA special-purpose ranges that are never treated as public, independent of
|
|
36
|
+
#: the running Python's ``ipaddress.is_global`` (whose answers changed across
|
|
37
|
+
#: patch releases, e.g. CVE-2024-4032). Belt and braces with ``is_global``.
|
|
38
|
+
_SPECIAL_PURPOSE = tuple(
|
|
39
|
+
ipaddress.ip_network(cidr)
|
|
40
|
+
for cidr in (
|
|
41
|
+
"0.0.0.0/8",
|
|
42
|
+
"10.0.0.0/8",
|
|
43
|
+
"100.64.0.0/10",
|
|
44
|
+
"127.0.0.0/8",
|
|
45
|
+
"169.254.0.0/16",
|
|
46
|
+
"172.16.0.0/12",
|
|
47
|
+
"192.0.0.0/24",
|
|
48
|
+
"192.0.2.0/24",
|
|
49
|
+
"192.88.99.0/24",
|
|
50
|
+
"192.168.0.0/16",
|
|
51
|
+
"198.18.0.0/15",
|
|
52
|
+
"198.51.100.0/24",
|
|
53
|
+
"203.0.113.0/24",
|
|
54
|
+
"224.0.0.0/4",
|
|
55
|
+
"240.0.0.0/4",
|
|
56
|
+
"::/128",
|
|
57
|
+
"::1/128",
|
|
58
|
+
"::ffff:0:0:0/96",
|
|
59
|
+
"64:ff9b:1::/48",
|
|
60
|
+
"100::/64",
|
|
61
|
+
"2001::/23",
|
|
62
|
+
"2001:db8::/32",
|
|
63
|
+
"3fff::/20",
|
|
64
|
+
"5f00::/16",
|
|
65
|
+
"fc00::/7",
|
|
66
|
+
"fe80::/10",
|
|
67
|
+
"fec0::/10",
|
|
68
|
+
"ff00::/8",
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True, slots=True)
|
|
74
|
+
class Target:
|
|
75
|
+
"""A URL that passed URL-level policy checks."""
|
|
76
|
+
|
|
77
|
+
url: str
|
|
78
|
+
scheme: str
|
|
79
|
+
host: str
|
|
80
|
+
port: int
|
|
81
|
+
is_ip_literal: bool
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def origin(self) -> tuple[str, str, int]:
|
|
85
|
+
return (self.scheme, self.host, self.port)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def check_url(url: str, policy: NetworkPolicy) -> Target:
|
|
89
|
+
"""Validate ``url`` against ``policy``. Raises :class:`NetworkBlocked`."""
|
|
90
|
+
if _CONTROL.search(url) or "\\" in url or " " in url:
|
|
91
|
+
raise NetworkBlocked("URL contains forbidden characters")
|
|
92
|
+
try:
|
|
93
|
+
parts = urlsplit(url)
|
|
94
|
+
port = parts.port
|
|
95
|
+
except ValueError as exc:
|
|
96
|
+
raise NetworkBlocked("URL could not be parsed") from exc
|
|
97
|
+
scheme = parts.scheme.lower()
|
|
98
|
+
if scheme == "http" and not policy.allow_http:
|
|
99
|
+
raise NetworkBlocked("plain HTTP is not allowed for this service")
|
|
100
|
+
if scheme not in ("http", "https"):
|
|
101
|
+
raise NetworkBlocked(f"scheme {scheme!r} is not allowed")
|
|
102
|
+
if "@" in parts.netloc or parts.username is not None or parts.password is not None:
|
|
103
|
+
raise NetworkBlocked("URLs with userinfo are not allowed")
|
|
104
|
+
host = (parts.hostname or "").rstrip(".")
|
|
105
|
+
if not host:
|
|
106
|
+
raise NetworkBlocked("URL has no host")
|
|
107
|
+
ip = _parse_ip_literal(host)
|
|
108
|
+
if ip is None:
|
|
109
|
+
try:
|
|
110
|
+
host = host.encode("idna").decode("ascii").lower()
|
|
111
|
+
except UnicodeError as exc:
|
|
112
|
+
raise NetworkBlocked("host name is not valid") from exc
|
|
113
|
+
if not _HOST_CHARS.match(host) or ".." in host:
|
|
114
|
+
raise NetworkBlocked("host name is not valid")
|
|
115
|
+
if _looks_numeric(host):
|
|
116
|
+
# "2130706433", "0x7f.1", "017700000001": resolvers may treat these as IPs.
|
|
117
|
+
raise NetworkBlocked("numeric host names are not allowed")
|
|
118
|
+
else:
|
|
119
|
+
host = str(ip)
|
|
120
|
+
check_ip(ip, policy)
|
|
121
|
+
if not host_allowed(host, policy):
|
|
122
|
+
raise NetworkBlocked(f"host {host!r} is not in the service allowlist")
|
|
123
|
+
effective_port = port if port is not None else (443 if scheme == "https" else 80)
|
|
124
|
+
if effective_port not in policy.allow_ports:
|
|
125
|
+
raise NetworkBlocked(f"port {effective_port} is not allowed")
|
|
126
|
+
return Target(url, scheme, host, effective_port, ip is not None)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _parse_ip_literal(host: str) -> IPAddress | None:
|
|
130
|
+
try:
|
|
131
|
+
return ipaddress.ip_address(host.strip("[]"))
|
|
132
|
+
except ValueError:
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _looks_numeric(host: str) -> bool:
|
|
137
|
+
labels = host.split(".")
|
|
138
|
+
return all(re.fullmatch(r"(0x[0-9a-f]*|[0-9]+)", label) for label in labels if label)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@lru_cache(maxsize=256)
|
|
142
|
+
def _networks(cidrs: tuple[str, ...]) -> tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...]:
|
|
143
|
+
return tuple(ipaddress.ip_network(c, strict=False) for c in cidrs)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _embedded_ipv4(ip: IPAddress) -> ipaddress.IPv4Address | None:
|
|
147
|
+
if isinstance(ip, ipaddress.IPv4Address):
|
|
148
|
+
return None
|
|
149
|
+
if ip.ipv4_mapped is not None:
|
|
150
|
+
return ip.ipv4_mapped
|
|
151
|
+
if ip.sixtofour is not None:
|
|
152
|
+
return ip.sixtofour
|
|
153
|
+
if ip.teredo is not None:
|
|
154
|
+
return ip.teredo[1]
|
|
155
|
+
if ip in _NAT64 or ip in _NAT64_LOCAL:
|
|
156
|
+
return ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF)
|
|
157
|
+
# Deprecated IPv4-compatible addresses (::a.b.c.d).
|
|
158
|
+
if int(ip) >> 32 == 0 and int(ip) > 1:
|
|
159
|
+
return ipaddress.IPv4Address(int(ip))
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def is_public_ip(ip: IPAddress) -> bool:
|
|
164
|
+
"""True if ``ip`` is a globally routable unicast address."""
|
|
165
|
+
embedded = _embedded_ipv4(ip)
|
|
166
|
+
if embedded is not None and not is_public_ip(embedded):
|
|
167
|
+
return False
|
|
168
|
+
if any(ip in net for net in _SPECIAL_PURPOSE if net.version == ip.version):
|
|
169
|
+
return False
|
|
170
|
+
return bool(ip.is_global) and not ip.is_multicast and not ip.is_unspecified
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def check_ip(ip: IPAddress | str, policy: NetworkPolicy) -> None:
|
|
174
|
+
"""Raise :class:`NetworkBlocked` unless ``ip`` may be contacted under ``policy``.
|
|
175
|
+
|
|
176
|
+
Non-public addresses (loopback, private, link-local including cloud metadata,
|
|
177
|
+
CGNAT, ULA, multicast, reserved, and IPv4 embedded in IPv6) are blocked unless
|
|
178
|
+
they fall inside one of ``policy.allow_networks``.
|
|
179
|
+
"""
|
|
180
|
+
address = ipaddress.ip_address(ip) if isinstance(ip, str) else ip
|
|
181
|
+
trusted = _networks(policy.allow_networks)
|
|
182
|
+
if any(address in net for net in trusted if net.version == address.version):
|
|
183
|
+
return
|
|
184
|
+
embedded = _embedded_ipv4(address)
|
|
185
|
+
if embedded is not None and any(embedded in net for net in trusted if net.version == 4):
|
|
186
|
+
return
|
|
187
|
+
if is_public_ip(address):
|
|
188
|
+
return
|
|
189
|
+
raise NetworkBlocked(f"address {address} is not a public address and is not allowlisted")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# --------------------------------------------------------------------------- building
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def encode_path_param(name: str, value: Any) -> str:
|
|
196
|
+
"""Encode one path parameter strictly. Raises ``invalid_arguments``."""
|
|
197
|
+
if isinstance(value, bool) or not isinstance(value, str | int | float):
|
|
198
|
+
raise ToolFailure(ErrorCode.INVALID_ARGUMENTS, f"{name}: path values must be scalars")
|
|
199
|
+
text = str(value)
|
|
200
|
+
if (
|
|
201
|
+
text == ""
|
|
202
|
+
or text in (".", "..")
|
|
203
|
+
or _CONTROL.search(text)
|
|
204
|
+
or any(c in text for c in "/\\?#")
|
|
205
|
+
):
|
|
206
|
+
raise ToolFailure(
|
|
207
|
+
ErrorCode.INVALID_ARGUMENTS,
|
|
208
|
+
f"{name}: value is not allowed in a URL path segment",
|
|
209
|
+
)
|
|
210
|
+
lowered = text.lower()
|
|
211
|
+
if "%2e" in lowered or "%2f" in lowered or "%5c" in lowered:
|
|
212
|
+
raise ToolFailure(
|
|
213
|
+
ErrorCode.INVALID_ARGUMENTS,
|
|
214
|
+
f"{name}: encoded path separators are not allowed",
|
|
215
|
+
)
|
|
216
|
+
return quote(text, safe="")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def build_url(
|
|
220
|
+
base_url: str,
|
|
221
|
+
path_template: str,
|
|
222
|
+
path_values: Mapping[str, Any],
|
|
223
|
+
query: Sequence[tuple[str, Any]],
|
|
224
|
+
) -> str:
|
|
225
|
+
"""Join trusted base URL and template with encoded values and query pairs."""
|
|
226
|
+
path = path_template
|
|
227
|
+
for name, value in path_values.items():
|
|
228
|
+
path = path.replace("{" + name + "}", encode_path_param(name, value))
|
|
229
|
+
base = urlsplit(base_url)
|
|
230
|
+
full_path = (base.path.rstrip("/") + path) if path != "/" else (base.path or "/")
|
|
231
|
+
url = f"{base.scheme}://{base.netloc}{full_path}"
|
|
232
|
+
pairs = _flatten_query(query)
|
|
233
|
+
if pairs:
|
|
234
|
+
url += "?" + urlencode(pairs, doseq=False)
|
|
235
|
+
built = urlsplit(url)
|
|
236
|
+
if built.netloc != base.netloc or not built.path.startswith(base.path.rstrip("/") or "/"):
|
|
237
|
+
raise NetworkBlocked("constructed URL escaped the service base URL")
|
|
238
|
+
return url
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _flatten_query(query: Sequence[tuple[str, Any]]) -> list[tuple[str, str]]:
|
|
242
|
+
out: list[tuple[str, str]] = []
|
|
243
|
+
for name, value in query:
|
|
244
|
+
if value is None:
|
|
245
|
+
continue
|
|
246
|
+
values = value if isinstance(value, list) else [value]
|
|
247
|
+
for item in values:
|
|
248
|
+
if isinstance(item, dict | list):
|
|
249
|
+
raise ToolFailure(
|
|
250
|
+
ErrorCode.INVALID_ARGUMENTS, f"{name}: query values must be scalars"
|
|
251
|
+
)
|
|
252
|
+
if isinstance(item, bool):
|
|
253
|
+
out.append((name, "true" if item else "false"))
|
|
254
|
+
else:
|
|
255
|
+
out.append((name, str(item)))
|
|
256
|
+
return out
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def same_origin(a: str, b: str) -> bool:
|
|
260
|
+
pa, pb = urlsplit(a), urlsplit(b)
|
|
261
|
+
port_a = pa.port or (443 if pa.scheme == "https" else 80)
|
|
262
|
+
port_b = pb.port or (443 if pb.scheme == "https" else 80)
|
|
263
|
+
return (pa.scheme, (pa.hostname or "").lower(), port_a) == (
|
|
264
|
+
pb.scheme,
|
|
265
|
+
(pb.hostname or "").lower(),
|
|
266
|
+
port_b,
|
|
267
|
+
)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Structured events for logging, metrics and tracing.
|
|
2
|
+
|
|
3
|
+
Events carry identifiers, timings and outcomes only. Argument values, request
|
|
4
|
+
bodies, response bodies and credentials are never included.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("toolbound")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class Event:
|
|
18
|
+
"""One observable step.
|
|
19
|
+
|
|
20
|
+
``name`` is one of ``call.start``, ``call.end``, ``http.attempt``,
|
|
21
|
+
``approval.required``, ``approval.granted``, ``policy.denied``,
|
|
22
|
+
``network.blocked``, ``schema.warning``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
name: str
|
|
26
|
+
tool: str
|
|
27
|
+
actor: str
|
|
28
|
+
request_id: str
|
|
29
|
+
tenant: str | None = None
|
|
30
|
+
outcome: str | None = None
|
|
31
|
+
duration_ms: float | None = None
|
|
32
|
+
http_status: int | None = None
|
|
33
|
+
attempt: int | None = None
|
|
34
|
+
attributes: dict[str, Any] = field(default_factory=dict)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class NullObserver:
|
|
38
|
+
def on_event(self, event: Event) -> None:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class LoggingObserver:
|
|
43
|
+
"""Logs events to the ``toolbound`` logger at INFO (failures at WARNING)."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, log: logging.Logger | None = None) -> None:
|
|
46
|
+
self._log = log or logger
|
|
47
|
+
|
|
48
|
+
def on_event(self, event: Event) -> None:
|
|
49
|
+
level = logging.WARNING if event.outcome not in (None, "ok") else logging.INFO
|
|
50
|
+
self._log.log(
|
|
51
|
+
level,
|
|
52
|
+
"%s tool=%s actor=%s outcome=%s status=%s duration_ms=%s request_id=%s",
|
|
53
|
+
event.name,
|
|
54
|
+
event.tool,
|
|
55
|
+
event.actor,
|
|
56
|
+
event.outcome,
|
|
57
|
+
event.http_status,
|
|
58
|
+
None if event.duration_ms is None else round(event.duration_ms, 2),
|
|
59
|
+
event.request_id,
|
|
60
|
+
)
|
toolbound/core/paths.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""A tiny, safe path language for selecting values from JSON.
|
|
2
|
+
|
|
3
|
+
Grammar (no evaluation, no functions, no wildcards over keys)::
|
|
4
|
+
|
|
5
|
+
path := "$" | step ( "." step )*
|
|
6
|
+
step := name suffix* | suffix+
|
|
7
|
+
suffix := "[]" | "[" integer "]"
|
|
8
|
+
name := [A-Za-z0-9_\\-@:]+
|
|
9
|
+
|
|
10
|
+
Examples: ``title``, ``user.login``, ``labels[].name``, ``items[0].id``,
|
|
11
|
+
``[].id`` (each element of a root array), ``$`` (the whole document).
|
|
12
|
+
|
|
13
|
+
Paths are parsed once at registration time. Evaluation is linear in the size of
|
|
14
|
+
the selected data and can never execute code or reach outside the document.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Any, Final
|
|
22
|
+
|
|
23
|
+
from .errors import ConfigurationError
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _Missing:
|
|
27
|
+
_instance: _Missing | None = None
|
|
28
|
+
|
|
29
|
+
def __new__(cls) -> _Missing:
|
|
30
|
+
if cls._instance is None:
|
|
31
|
+
cls._instance = super().__new__(cls)
|
|
32
|
+
return cls._instance
|
|
33
|
+
|
|
34
|
+
def __repr__(self) -> str:
|
|
35
|
+
return "MISSING"
|
|
36
|
+
|
|
37
|
+
def __bool__(self) -> bool:
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
MISSING: Final = _Missing()
|
|
42
|
+
"""Sentinel returned when a path does not exist in the document."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class Key:
|
|
47
|
+
name: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class Index:
|
|
52
|
+
index: int
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, slots=True)
|
|
56
|
+
class Each:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
Step = Key | Index | Each
|
|
61
|
+
|
|
62
|
+
_NAME = re.compile(r"[A-Za-z0-9_\-@:]+")
|
|
63
|
+
_SUFFIX = re.compile(r"\[(\d*)\]")
|
|
64
|
+
_MAX_PATH_LENGTH = 256
|
|
65
|
+
_MAX_STEPS = 32
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True, slots=True)
|
|
69
|
+
class Path:
|
|
70
|
+
"""A parsed, immutable path."""
|
|
71
|
+
|
|
72
|
+
text: str
|
|
73
|
+
steps: tuple[Step, ...]
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def is_root(self) -> bool:
|
|
77
|
+
return not self.steps
|
|
78
|
+
|
|
79
|
+
def get(self, data: Any) -> Any:
|
|
80
|
+
"""Return the selected value, or :data:`MISSING`."""
|
|
81
|
+
return _walk(data, self.steps)
|
|
82
|
+
|
|
83
|
+
def __str__(self) -> str:
|
|
84
|
+
return self.text
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def parse_path(text: str) -> Path:
|
|
88
|
+
"""Parse ``text`` into a :class:`Path`, raising :class:`ConfigurationError`."""
|
|
89
|
+
if not isinstance(text, str) or not text:
|
|
90
|
+
raise ConfigurationError("path must be a non-empty string")
|
|
91
|
+
if len(text) > _MAX_PATH_LENGTH:
|
|
92
|
+
raise ConfigurationError(f"path is longer than {_MAX_PATH_LENGTH} characters")
|
|
93
|
+
if text == "$":
|
|
94
|
+
return Path(text, ())
|
|
95
|
+
steps: list[Step] = []
|
|
96
|
+
for raw in text.split("."):
|
|
97
|
+
if not raw:
|
|
98
|
+
raise ConfigurationError(f"invalid path {text!r}: empty segment")
|
|
99
|
+
pos = 0
|
|
100
|
+
match = _NAME.match(raw)
|
|
101
|
+
if match:
|
|
102
|
+
steps.append(Key(match.group(0)))
|
|
103
|
+
pos = match.end()
|
|
104
|
+
elif not raw.startswith("["):
|
|
105
|
+
raise ConfigurationError(f"invalid path {text!r}: bad segment {raw!r}")
|
|
106
|
+
while pos < len(raw):
|
|
107
|
+
suffix = _SUFFIX.match(raw, pos)
|
|
108
|
+
if not suffix:
|
|
109
|
+
raise ConfigurationError(f"invalid path {text!r}: bad segment {raw!r}")
|
|
110
|
+
steps.append(Each() if suffix.group(1) == "" else Index(int(suffix.group(1))))
|
|
111
|
+
pos = suffix.end()
|
|
112
|
+
if len(steps) > _MAX_STEPS:
|
|
113
|
+
raise ConfigurationError(f"path {text!r} has more than {_MAX_STEPS} steps")
|
|
114
|
+
return Path(text, tuple(steps))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _walk(data: Any, steps: tuple[Step, ...]) -> Any:
|
|
118
|
+
current = data
|
|
119
|
+
for i, step in enumerate(steps):
|
|
120
|
+
if isinstance(step, Key):
|
|
121
|
+
if not isinstance(current, dict) or step.name not in current:
|
|
122
|
+
return MISSING
|
|
123
|
+
current = current[step.name]
|
|
124
|
+
elif isinstance(step, Index):
|
|
125
|
+
if not isinstance(current, list) or step.index >= len(current):
|
|
126
|
+
return MISSING
|
|
127
|
+
current = current[step.index]
|
|
128
|
+
else:
|
|
129
|
+
if not isinstance(current, list):
|
|
130
|
+
return MISSING
|
|
131
|
+
rest = steps[i + 1 :]
|
|
132
|
+
out = []
|
|
133
|
+
for item in current:
|
|
134
|
+
value = _walk(item, rest)
|
|
135
|
+
if value is not MISSING:
|
|
136
|
+
out.append(value)
|
|
137
|
+
return out
|
|
138
|
+
return current
|
toolbound/core/ports.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Ports: the small interfaces through which the core talks to the outside world.
|
|
2
|
+
|
|
3
|
+
Each port has a default implementation; replace any of them by passing your own
|
|
4
|
+
object to :class:`~toolbound.Gateway`. All are plain :class:`typing.Protocol`
|
|
5
|
+
types: no base class to inherit from, no registration.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Awaitable, Mapping
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
|
13
|
+
|
|
14
|
+
from .contracts import NetworkPolicy
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from .approval import PendingApproval
|
|
18
|
+
from .context import ExecutionContext
|
|
19
|
+
from .contracts import ToolSpec
|
|
20
|
+
from .observe import Event
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# --------------------------------------------------------------------------- transport
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class HttpRequest:
|
|
28
|
+
"""A fully built request that already passed URL-level policy checks."""
|
|
29
|
+
|
|
30
|
+
method: str
|
|
31
|
+
url: str
|
|
32
|
+
headers: tuple[tuple[str, str], ...]
|
|
33
|
+
body: bytes | None
|
|
34
|
+
network: NetworkPolicy
|
|
35
|
+
timeout: float
|
|
36
|
+
connect_timeout: float
|
|
37
|
+
max_response_bytes: int
|
|
38
|
+
|
|
39
|
+
def __repr__(self) -> str:
|
|
40
|
+
# Headers, body and query values can hold credentials: never print them.
|
|
41
|
+
base, _, query = self.url.partition("?")
|
|
42
|
+
names = sorted({k.lower() for k, _ in self.headers})
|
|
43
|
+
body = f"<{len(self.body)} bytes>" if self.body else None
|
|
44
|
+
return (
|
|
45
|
+
f"HttpRequest(method={self.method!r}, url={base + ('?…' if query else '')!r}, "
|
|
46
|
+
f"headers={names!r}, body={body})"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class HttpResponse:
|
|
52
|
+
"""A bounded upstream response. ``headers`` keys are lower-case."""
|
|
53
|
+
|
|
54
|
+
status: int
|
|
55
|
+
headers: Mapping[str, str]
|
|
56
|
+
body: bytes
|
|
57
|
+
url: str
|
|
58
|
+
elapsed_ms: float = 0.0
|
|
59
|
+
extensions: Mapping[str, Any] = field(default_factory=dict)
|
|
60
|
+
|
|
61
|
+
def __repr__(self) -> str:
|
|
62
|
+
return (
|
|
63
|
+
f"HttpResponse(status={self.status}, url={self.url.partition('?')[0]!r}, "
|
|
64
|
+
f"body=<{len(self.body)} bytes>)"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@runtime_checkable
|
|
69
|
+
class Transport(Protocol):
|
|
70
|
+
"""Sends one HTTP request. Must not follow redirects (the core does that).
|
|
71
|
+
|
|
72
|
+
Production transports must enforce IP-level policy with
|
|
73
|
+
:func:`toolbound.core.network.check_ip` on the address they connect to, cap
|
|
74
|
+
the (decompressed) body at ``max_response_bytes`` and raise:
|
|
75
|
+
|
|
76
|
+
* :class:`toolbound.core.errors.NetworkBlocked` for policy violations,
|
|
77
|
+
* :class:`toolbound.core.errors.ToolFailure` with ``TIMEOUT``,
|
|
78
|
+
``UPSTREAM_UNAVAILABLE`` or ``RESPONSE_TOO_LARGE`` for transport failures.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
async def send(self, request: HttpRequest) -> HttpResponse: ...
|
|
82
|
+
|
|
83
|
+
async def aclose(self) -> None: ...
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# --------------------------------------------------------------------------- credentials
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@runtime_checkable
|
|
90
|
+
class CredentialProvider(Protocol):
|
|
91
|
+
"""Returns the secret value for a credential *name* (sync or async)."""
|
|
92
|
+
|
|
93
|
+
def get(self, name: str, ctx: ExecutionContext) -> str | Awaitable[str | None] | None: ...
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# --------------------------------------------------------------------------- policy
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True, slots=True)
|
|
100
|
+
class AuthorizationRequest:
|
|
101
|
+
"""What the authorizer sees: who, which tool, which validated arguments."""
|
|
102
|
+
|
|
103
|
+
ctx: ExecutionContext
|
|
104
|
+
tool: ToolSpec
|
|
105
|
+
arguments: Mapping[str, Any]
|
|
106
|
+
bound: Mapping[str, Any]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True, slots=True)
|
|
110
|
+
class Decision:
|
|
111
|
+
"""An authorization decision. ``reason`` is shown to the model when denied."""
|
|
112
|
+
|
|
113
|
+
allowed: bool
|
|
114
|
+
reason: str = "not permitted"
|
|
115
|
+
|
|
116
|
+
@classmethod
|
|
117
|
+
def allow(cls) -> Decision:
|
|
118
|
+
return cls(True, "")
|
|
119
|
+
|
|
120
|
+
@classmethod
|
|
121
|
+
def deny(cls, reason: str = "not permitted") -> Decision:
|
|
122
|
+
return cls(False, reason)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class Authorizer(Protocol):
|
|
126
|
+
"""Application-owned policy hook (sync or async), returning bool or Decision."""
|
|
127
|
+
|
|
128
|
+
def __call__(
|
|
129
|
+
self, request: AuthorizationRequest
|
|
130
|
+
) -> bool | Decision | Awaitable[bool | Decision]: ...
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class Approver(Protocol):
|
|
134
|
+
"""Inline human-approval hook (sync or async). Return True to approve."""
|
|
135
|
+
|
|
136
|
+
def __call__(self, pending: PendingApproval) -> bool | Awaitable[bool]: ...
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# --------------------------------------------------------------------------- utilities
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@runtime_checkable
|
|
143
|
+
class TokenEstimator(Protocol):
|
|
144
|
+
"""Estimates how many tokens a string uses for a model."""
|
|
145
|
+
|
|
146
|
+
def count(self, text: str) -> int: ...
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@runtime_checkable
|
|
150
|
+
class Observer(Protocol):
|
|
151
|
+
"""Receives structured events. Events never contain argument or body values."""
|
|
152
|
+
|
|
153
|
+
def on_event(self, event: Event) -> None: ...
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@runtime_checkable
|
|
157
|
+
class StateStore(Protocol):
|
|
158
|
+
"""Shared state for replay protection (single-use approvals, call-id dedupe).
|
|
159
|
+
|
|
160
|
+
The default is in-memory and per-process. For multiple workers, implement
|
|
161
|
+
``claim`` on a shared store (for example Redis ``SET key 1 NX EX ttl``).
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
async def claim(self, key: str, ttl_seconds: float) -> bool:
|
|
165
|
+
"""Atomically record ``key``. Return False if it was already present."""
|
|
166
|
+
...
|