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,689 @@
|
|
|
1
|
+
"""Contract types: the developer-controlled definition of what a tool may do.
|
|
2
|
+
|
|
3
|
+
Everything in this module is **trusted configuration**. The model never sees
|
|
4
|
+
URLs, credentials, bound parameters, network policy, or limits. It only sees a
|
|
5
|
+
tool's name, description, and input schema.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Callable, Mapping
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from enum import StrEnum
|
|
14
|
+
from typing import Any, Literal
|
|
15
|
+
from urllib.parse import urlsplit
|
|
16
|
+
|
|
17
|
+
from .context import ExecutionContext
|
|
18
|
+
from .errors import ConfigurationError
|
|
19
|
+
from .paths import MISSING
|
|
20
|
+
|
|
21
|
+
_TOOL_NAME = re.compile(r"^[A-Za-z][A-Za-z0-9_-]{0,63}$")
|
|
22
|
+
_HEADER_NAME = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$")
|
|
23
|
+
_CREDENTIAL_NAME = re.compile(r"^[A-Za-z0-9_.:-]{1,128}$")
|
|
24
|
+
_FORBIDDEN_STATIC_HEADERS = frozenset(
|
|
25
|
+
{
|
|
26
|
+
"host",
|
|
27
|
+
"content-length",
|
|
28
|
+
"transfer-encoding",
|
|
29
|
+
"connection",
|
|
30
|
+
"keep-alive",
|
|
31
|
+
"upgrade",
|
|
32
|
+
"te",
|
|
33
|
+
"trailer",
|
|
34
|
+
"proxy-authorization",
|
|
35
|
+
"proxy-connection",
|
|
36
|
+
"authorization",
|
|
37
|
+
"cookie",
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
_MAX_DESCRIPTION = 1024
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Effect(StrEnum):
|
|
44
|
+
"""What a tool does to the outside world. Drives approval and retry rules."""
|
|
45
|
+
|
|
46
|
+
READ = "read"
|
|
47
|
+
WRITE = "write"
|
|
48
|
+
DESTRUCTIVE = "destructive"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# --------------------------------------------------------------------------- bindings
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class FromContext:
|
|
56
|
+
"""Bind a request parameter to a value from the trusted :class:`ExecutionContext`.
|
|
57
|
+
|
|
58
|
+
``path`` is one of ``"actor"``, ``"tenant"``, ``"request_id"`` or
|
|
59
|
+
``"attributes.<key>"``.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
path: str
|
|
63
|
+
|
|
64
|
+
def __post_init__(self) -> None:
|
|
65
|
+
if self.path in {"actor", "tenant", "request_id"}:
|
|
66
|
+
return
|
|
67
|
+
if self.path.startswith("attributes.") and len(self.path) > len("attributes."):
|
|
68
|
+
return
|
|
69
|
+
raise ConfigurationError(
|
|
70
|
+
f"invalid context binding {self.path!r}: use 'actor', 'tenant', "
|
|
71
|
+
"'request_id' or 'attributes.<key>'"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def resolve(self, ctx: ExecutionContext) -> Any:
|
|
75
|
+
if self.path == "actor":
|
|
76
|
+
return ctx.actor
|
|
77
|
+
if self.path == "tenant":
|
|
78
|
+
if ctx.tenant is None:
|
|
79
|
+
raise LookupError("tenant")
|
|
80
|
+
return ctx.tenant
|
|
81
|
+
if self.path == "request_id":
|
|
82
|
+
return ctx.request_id
|
|
83
|
+
key = self.path.removeprefix("attributes.")
|
|
84
|
+
if key not in ctx.attributes:
|
|
85
|
+
raise LookupError(key)
|
|
86
|
+
return ctx.attributes[key]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True, slots=True)
|
|
90
|
+
class Const:
|
|
91
|
+
"""Bind a request parameter to a fixed value."""
|
|
92
|
+
|
|
93
|
+
value: Any
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
Binding = FromContext | Const | Callable[[ExecutionContext], Any]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def from_ctx(path: str) -> FromContext:
|
|
100
|
+
"""Shorthand for :class:`FromContext`: ``from_ctx("attributes.repo_owner")``."""
|
|
101
|
+
return FromContext(path)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def parse_binding(value: Any) -> Binding:
|
|
105
|
+
"""Parse a declarative binding (``"ctx.actor"`` or ``{"const": 1}``)."""
|
|
106
|
+
if isinstance(value, FromContext | Const) or callable(value):
|
|
107
|
+
return value # type: ignore[no-any-return]
|
|
108
|
+
if isinstance(value, str) and value.startswith("ctx."):
|
|
109
|
+
return FromContext(value.removeprefix("ctx."))
|
|
110
|
+
if isinstance(value, Mapping) and set(value) == {"const"}:
|
|
111
|
+
return Const(value["const"])
|
|
112
|
+
raise ConfigurationError(
|
|
113
|
+
f"invalid binding {value!r}: use 'ctx.actor', 'ctx.tenant', "
|
|
114
|
+
"'ctx.attributes.<key>' or {const: <value>}"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def resolve_binding(binding: Binding, ctx: ExecutionContext) -> Any:
|
|
119
|
+
if isinstance(binding, FromContext):
|
|
120
|
+
return binding.resolve(ctx)
|
|
121
|
+
if isinstance(binding, Const):
|
|
122
|
+
return binding.value
|
|
123
|
+
return binding(ctx)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# --------------------------------------------------------------------------- auth
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True, slots=True)
|
|
130
|
+
class NoAuth:
|
|
131
|
+
"""No credentials are attached."""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True, slots=True)
|
|
135
|
+
class BearerAuth:
|
|
136
|
+
"""``Authorization: Bearer <credential>``. ``credential`` is a *name*, not a value."""
|
|
137
|
+
|
|
138
|
+
credential: str
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass(frozen=True, slots=True)
|
|
142
|
+
class ApiKeyAuth:
|
|
143
|
+
"""An API key sent in a header (default ``X-API-Key``) or a query parameter.
|
|
144
|
+
|
|
145
|
+
Prefer headers: query-string keys are more likely to end up in upstream logs.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
credential: str
|
|
149
|
+
header: str | None = "X-API-Key"
|
|
150
|
+
query: str | None = None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass(frozen=True, slots=True)
|
|
154
|
+
class BasicAuth:
|
|
155
|
+
"""HTTP Basic auth. The credential value must be ``"username:password"``."""
|
|
156
|
+
|
|
157
|
+
credential: str
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
Auth = NoAuth | BearerAuth | ApiKeyAuth | BasicAuth
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _validate_auth(auth: Auth) -> None:
|
|
164
|
+
if isinstance(auth, NoAuth):
|
|
165
|
+
return
|
|
166
|
+
if not isinstance(auth, BearerAuth | ApiKeyAuth | BasicAuth):
|
|
167
|
+
raise ConfigurationError(f"unsupported auth type {type(auth).__name__}")
|
|
168
|
+
if not _CREDENTIAL_NAME.match(auth.credential):
|
|
169
|
+
raise ConfigurationError(
|
|
170
|
+
"auth.credential must be a credential *name* (letters, digits, _.:-), "
|
|
171
|
+
"never the secret value itself"
|
|
172
|
+
)
|
|
173
|
+
if isinstance(auth, ApiKeyAuth):
|
|
174
|
+
if (auth.header is None) == (auth.query is None):
|
|
175
|
+
raise ConfigurationError("ApiKeyAuth needs exactly one of header= or query=")
|
|
176
|
+
if auth.header is not None:
|
|
177
|
+
_validate_header_name(auth.header, allow_reserved=False)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# --------------------------------------------------------------------------- policies
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass(frozen=True, slots=True)
|
|
184
|
+
class NetworkPolicy:
|
|
185
|
+
"""Where requests for a service may go.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
allow_hosts: Exact hostnames (or ``*.example.com`` for subdomains).
|
|
189
|
+
Defaults to the host of ``Service.base_url``.
|
|
190
|
+
allow_ports: Allowed ports. Defaults to 443 (and 80 when ``allow_http``).
|
|
191
|
+
allow_http: Permit plain-HTTP targets. Off by default.
|
|
192
|
+
allow_networks: CIDR ranges that are trusted even though they are
|
|
193
|
+
private, loopback, or link-local (for internal services). Leave empty
|
|
194
|
+
for internet APIs: non-public addresses are then always blocked.
|
|
195
|
+
follow_redirects: Follow 3xx responses. Each hop is re-checked against
|
|
196
|
+
this policy, and credentials are dropped on a change of origin.
|
|
197
|
+
max_redirects: Maximum hops when ``follow_redirects`` is on.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
allow_hosts: tuple[str, ...] = ()
|
|
201
|
+
allow_ports: tuple[int, ...] = ()
|
|
202
|
+
allow_http: bool = False
|
|
203
|
+
allow_networks: tuple[str, ...] = ()
|
|
204
|
+
follow_redirects: bool = False
|
|
205
|
+
max_redirects: int = 3
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@dataclass(frozen=True, slots=True)
|
|
209
|
+
class Limits:
|
|
210
|
+
"""Size and time limits for one upstream service."""
|
|
211
|
+
|
|
212
|
+
timeout: float = 15.0
|
|
213
|
+
connect_timeout: float = 5.0
|
|
214
|
+
max_request_bytes: int = 256 * 1024
|
|
215
|
+
max_response_bytes: int = 5 * 1024 * 1024
|
|
216
|
+
max_concurrency: int = 8
|
|
217
|
+
#: Deadline for one tool call including every retry, backoff and wait for a
|
|
218
|
+
#: concurrency slot. ``timeout`` bounds a single attempt.
|
|
219
|
+
total_timeout: float = 60.0
|
|
220
|
+
|
|
221
|
+
def __post_init__(self) -> None:
|
|
222
|
+
if self.timeout <= 0 or self.connect_timeout <= 0 or self.total_timeout <= 0:
|
|
223
|
+
raise ConfigurationError("timeouts must be positive")
|
|
224
|
+
if self.total_timeout < self.timeout:
|
|
225
|
+
raise ConfigurationError("total_timeout must be at least timeout")
|
|
226
|
+
if self.max_request_bytes <= 0 or self.max_response_bytes <= 0:
|
|
227
|
+
raise ConfigurationError("byte limits must be positive")
|
|
228
|
+
if self.max_concurrency < 1:
|
|
229
|
+
raise ConfigurationError("max_concurrency must be at least 1")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@dataclass(frozen=True, slots=True)
|
|
233
|
+
class RetryPolicy:
|
|
234
|
+
"""Bounded retries with exponential backoff and full jitter.
|
|
235
|
+
|
|
236
|
+
Retries only happen for safe methods (GET, HEAD, OPTIONS) or for operations
|
|
237
|
+
that declare an ``idempotency_header``. Non-idempotent calls are never
|
|
238
|
+
repeated automatically.
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
max_attempts: int = 3
|
|
242
|
+
backoff_base: float = 0.25
|
|
243
|
+
backoff_max: float = 4.0
|
|
244
|
+
retry_statuses: frozenset[int] = frozenset({429, 502, 503, 504})
|
|
245
|
+
max_retry_after: float = 30.0
|
|
246
|
+
|
|
247
|
+
def __post_init__(self) -> None:
|
|
248
|
+
if self.max_attempts < 1:
|
|
249
|
+
raise ConfigurationError("max_attempts must be at least 1")
|
|
250
|
+
if self.backoff_base < 0 or self.backoff_max < 0 or self.max_retry_after < 0:
|
|
251
|
+
raise ConfigurationError("backoff values must be non-negative")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@dataclass(frozen=True, slots=True)
|
|
255
|
+
class RateLimit:
|
|
256
|
+
"""At most ``calls`` executions per ``per_seconds`` window, per ``scope``."""
|
|
257
|
+
|
|
258
|
+
calls: int
|
|
259
|
+
per_seconds: float
|
|
260
|
+
scope: Literal["actor", "tenant", "global"] = "actor"
|
|
261
|
+
|
|
262
|
+
def __post_init__(self) -> None:
|
|
263
|
+
if self.calls < 1 or self.per_seconds <= 0:
|
|
264
|
+
raise ConfigurationError("rate limit needs calls >= 1 and per_seconds > 0")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# --------------------------------------------------------------------------- output
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@dataclass(frozen=True, slots=True)
|
|
271
|
+
class Field:
|
|
272
|
+
"""A projected output field with optional limits and transforms.
|
|
273
|
+
|
|
274
|
+
A plain string in a shape (``"author": "user.login"``) is shorthand for
|
|
275
|
+
``Field("user.login")``.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
source: Path into the upstream value (see :mod:`toolbound.core.paths`).
|
|
279
|
+
max_chars: Per-field string limit, overriding ``Output.max_chars``.
|
|
280
|
+
max_items: Per-field list limit, overriding ``Output.max_items``.
|
|
281
|
+
transform: A built-in transform name (``"lower"``, ``"upper"``,
|
|
282
|
+
``"strip"``, ``"count"``, ``"first"``, ``"join"``, ``"bool"``,
|
|
283
|
+
``"str"``) or a trusted Python callable.
|
|
284
|
+
default: Value used when the source path is missing.
|
|
285
|
+
shape: Nested shape applied to the value (to each element if a list).
|
|
286
|
+
"""
|
|
287
|
+
|
|
288
|
+
source: str
|
|
289
|
+
max_chars: int | None = None
|
|
290
|
+
max_items: int | None = None
|
|
291
|
+
transform: str | Callable[[Any], Any] | None = None
|
|
292
|
+
default: Any = MISSING
|
|
293
|
+
shape: Mapping[str, FieldSpec] | None = None
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
FieldSpec = str | Field
|
|
297
|
+
|
|
298
|
+
OverflowMode = Literal["truncate", "reject"]
|
|
299
|
+
SchemaMode = Literal["strict", "warn"]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _default_redact_keys() -> tuple[str, ...]:
|
|
303
|
+
return (
|
|
304
|
+
"password",
|
|
305
|
+
"passwd",
|
|
306
|
+
"secret",
|
|
307
|
+
"client_secret",
|
|
308
|
+
"token",
|
|
309
|
+
"access_token",
|
|
310
|
+
"refresh_token",
|
|
311
|
+
"id_token",
|
|
312
|
+
"api_key",
|
|
313
|
+
"apikey",
|
|
314
|
+
"authorization",
|
|
315
|
+
"cookie",
|
|
316
|
+
"set-cookie",
|
|
317
|
+
"private_key",
|
|
318
|
+
"session",
|
|
319
|
+
"session_id",
|
|
320
|
+
"x-api-key",
|
|
321
|
+
"client_assertion",
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@dataclass(frozen=True, slots=True)
|
|
326
|
+
class Output:
|
|
327
|
+
"""How an upstream response becomes the model-visible result.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
root: Path to the part of the response to keep (``"$"`` = everything).
|
|
331
|
+
If it selects a list, ``shape`` is applied to every element.
|
|
332
|
+
shape: Allowlist of output keys mapped to source paths. ``None`` passes
|
|
333
|
+
the selected value through (limits and redaction still apply).
|
|
334
|
+
Prefer an explicit shape: allowlists are the primary data-minimization
|
|
335
|
+
control, redaction is only a second layer.
|
|
336
|
+
required: Output keys that must never be dropped to save space (for
|
|
337
|
+
example identifiers or citations). If they cannot fit the budget the
|
|
338
|
+
call fails with ``result_too_large`` instead of returning a misleading
|
|
339
|
+
result.
|
|
340
|
+
max_items: Maximum elements in any list (including the root list).
|
|
341
|
+
max_chars: Maximum characters in any string.
|
|
342
|
+
max_tokens: Token budget for the ``data`` part of the result.
|
|
343
|
+
overflow: ``"truncate"`` (trim and report what was dropped) or
|
|
344
|
+
``"reject"`` (fail if anything would need to be dropped).
|
|
345
|
+
selectable: Expose an optional ``fields`` argument so the model can ask
|
|
346
|
+
for a subset of the top-level shape keys. Required keys are always
|
|
347
|
+
included.
|
|
348
|
+
redact_keys: Object keys (case-insensitive) whose values are replaced
|
|
349
|
+
with ``"[REDACTED]"`` anywhere in the result.
|
|
350
|
+
redact_secrets: Mask common secret formats (API keys, JWTs, private
|
|
351
|
+
keys) in string values.
|
|
352
|
+
redactors: Extra trusted hooks ``(path, value) -> value`` for PII rules.
|
|
353
|
+
schema: Optional JSON Schema the selected upstream value must match.
|
|
354
|
+
schema_mode: ``"strict"`` fails the call on mismatch; ``"warn"`` returns
|
|
355
|
+
the result with a warning.
|
|
356
|
+
error_shape: Optional shape applied to upstream error bodies so the model
|
|
357
|
+
sees a short, useful error message instead of only a status code.
|
|
358
|
+
accept_text: Accept ``text/*`` responses (returned as a string).
|
|
359
|
+
transform: Final trusted transform applied after shaping.
|
|
360
|
+
continuation: For read tools that return lists: when items are left
|
|
361
|
+
out (``max_items`` or the budget), return a signed ``next_cursor``
|
|
362
|
+
that resumes at the next item. Enabled automatically for operations
|
|
363
|
+
with ``pagination``. Continuing re-runs the request, so it assumes
|
|
364
|
+
the upstream ordering is stable.
|
|
365
|
+
"""
|
|
366
|
+
|
|
367
|
+
root: str = "$"
|
|
368
|
+
shape: Mapping[str, FieldSpec] | None = None
|
|
369
|
+
required: tuple[str, ...] = ()
|
|
370
|
+
max_items: int = 50
|
|
371
|
+
max_chars: int = 4000
|
|
372
|
+
max_tokens: int | None = 2000
|
|
373
|
+
overflow: OverflowMode = "truncate"
|
|
374
|
+
selectable: bool = False
|
|
375
|
+
redact_keys: tuple[str, ...] = field(default_factory=_default_redact_keys)
|
|
376
|
+
redact_secrets: bool = True
|
|
377
|
+
redactors: tuple[Callable[[str, Any], Any], ...] = ()
|
|
378
|
+
schema: Mapping[str, Any] | None = None
|
|
379
|
+
schema_mode: SchemaMode = "strict"
|
|
380
|
+
error_shape: Mapping[str, FieldSpec] | None = None
|
|
381
|
+
accept_text: bool = False
|
|
382
|
+
transform: Callable[[Any], Any] | None = None
|
|
383
|
+
continuation: bool = False
|
|
384
|
+
|
|
385
|
+
def __post_init__(self) -> None:
|
|
386
|
+
if self.max_items < 1 or self.max_chars < 1:
|
|
387
|
+
raise ConfigurationError("max_items and max_chars must be at least 1")
|
|
388
|
+
if self.max_tokens is not None and self.max_tokens < 16:
|
|
389
|
+
raise ConfigurationError("max_tokens must be at least 16")
|
|
390
|
+
if self.overflow not in ("truncate", "reject"):
|
|
391
|
+
raise ConfigurationError("overflow must be 'truncate' or 'reject'")
|
|
392
|
+
if self.schema_mode not in ("strict", "warn"):
|
|
393
|
+
raise ConfigurationError("schema_mode must be 'strict' or 'warn'")
|
|
394
|
+
if self.required and self.shape is None:
|
|
395
|
+
raise ConfigurationError("Output.required needs an explicit shape")
|
|
396
|
+
if self.shape is not None:
|
|
397
|
+
missing = [k for k in self.required if k not in self.shape]
|
|
398
|
+
if missing:
|
|
399
|
+
raise ConfigurationError(f"required keys not in shape: {missing}")
|
|
400
|
+
if self.selectable and self.shape is None:
|
|
401
|
+
raise ConfigurationError("Output.selectable needs an explicit shape")
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
@dataclass(frozen=True, slots=True)
|
|
405
|
+
class Pagination:
|
|
406
|
+
"""Upstream pagination. The model only ever sees an opaque, signed cursor.
|
|
407
|
+
|
|
408
|
+
* ``cursor``: the next token is read from ``next_path`` in the response and
|
|
409
|
+
sent back in the ``param`` query parameter.
|
|
410
|
+
* ``link``: the RFC 8288 ``Link: <...>; rel="next"`` header is followed. The
|
|
411
|
+
URL must stay on the same origin and path, and is re-checked against the
|
|
412
|
+
network policy.
|
|
413
|
+
* ``page``: a page number in ``param`` starting at ``start``. If
|
|
414
|
+
``page_size`` is set, a short page ends the sequence.
|
|
415
|
+
"""
|
|
416
|
+
|
|
417
|
+
type: Literal["cursor", "link", "page"]
|
|
418
|
+
param: str | None = None
|
|
419
|
+
next_path: str | None = None
|
|
420
|
+
start: int = 1
|
|
421
|
+
page_size: int | None = None
|
|
422
|
+
|
|
423
|
+
def __post_init__(self) -> None:
|
|
424
|
+
if self.type == "cursor" and (not self.param or not self.next_path):
|
|
425
|
+
raise ConfigurationError("cursor pagination needs param= and next_path=")
|
|
426
|
+
if self.type == "page" and not self.param:
|
|
427
|
+
raise ConfigurationError("page pagination needs param=")
|
|
428
|
+
if self.type not in ("cursor", "link", "page"):
|
|
429
|
+
raise ConfigurationError("pagination type must be cursor, link or page")
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
# --------------------------------------------------------------------------- service
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
@dataclass(frozen=True, slots=True)
|
|
436
|
+
class Service:
|
|
437
|
+
"""A trusted upstream REST service: base URL, credentials and policies."""
|
|
438
|
+
|
|
439
|
+
name: str
|
|
440
|
+
base_url: str
|
|
441
|
+
auth: Auth = field(default_factory=NoAuth)
|
|
442
|
+
headers: Mapping[str, str] = field(default_factory=dict)
|
|
443
|
+
network: NetworkPolicy = field(default_factory=NetworkPolicy)
|
|
444
|
+
limits: Limits = field(default_factory=Limits)
|
|
445
|
+
retry: RetryPolicy = field(default_factory=RetryPolicy)
|
|
446
|
+
|
|
447
|
+
def __post_init__(self) -> None:
|
|
448
|
+
if not _TOOL_NAME.match(self.name):
|
|
449
|
+
raise ConfigurationError(f"invalid service name {self.name!r}")
|
|
450
|
+
parts = urlsplit(self.base_url)
|
|
451
|
+
if parts.scheme not in ("https", "http"):
|
|
452
|
+
raise ConfigurationError(f"service {self.name}: base_url must be http(s)")
|
|
453
|
+
if parts.scheme == "http" and not self.network.allow_http:
|
|
454
|
+
raise ConfigurationError(
|
|
455
|
+
f"service {self.name}: http:// base_url requires network.allow_http=True"
|
|
456
|
+
)
|
|
457
|
+
if parts.username is not None or parts.password is not None:
|
|
458
|
+
raise ConfigurationError(f"service {self.name}: base_url must not contain userinfo")
|
|
459
|
+
if parts.query or parts.fragment:
|
|
460
|
+
raise ConfigurationError(f"service {self.name}: base_url must not have query/fragment")
|
|
461
|
+
if not parts.hostname:
|
|
462
|
+
raise ConfigurationError(f"service {self.name}: base_url has no host")
|
|
463
|
+
_validate_auth(self.auth)
|
|
464
|
+
for name, value in self.headers.items():
|
|
465
|
+
_validate_header_name(name, allow_reserved=False)
|
|
466
|
+
_validate_header_value(name, value)
|
|
467
|
+
host = parts.hostname.lower()
|
|
468
|
+
net = self.network
|
|
469
|
+
hosts = tuple(h.lower().rstrip(".") for h in net.allow_hosts) or (host,)
|
|
470
|
+
if not any(_host_matches(host, pattern) for pattern in hosts):
|
|
471
|
+
raise ConfigurationError(
|
|
472
|
+
f"service {self.name}: base_url host {host!r} is not in network.allow_hosts"
|
|
473
|
+
)
|
|
474
|
+
default_port = 443 if parts.scheme == "https" else 80
|
|
475
|
+
ports = net.allow_ports or ((443, 80) if net.allow_http else (443,))
|
|
476
|
+
if (parts.port or default_port) not in ports:
|
|
477
|
+
raise ConfigurationError(
|
|
478
|
+
f"service {self.name}: base_url port is not in network.allow_ports"
|
|
479
|
+
)
|
|
480
|
+
if net.max_redirects < 0:
|
|
481
|
+
raise ConfigurationError("max_redirects must be >= 0")
|
|
482
|
+
import ipaddress
|
|
483
|
+
|
|
484
|
+
for cidr in net.allow_networks:
|
|
485
|
+
try:
|
|
486
|
+
ipaddress.ip_network(cidr, strict=False)
|
|
487
|
+
except ValueError as exc:
|
|
488
|
+
raise ConfigurationError(f"invalid CIDR in allow_networks: {cidr!r}") from exc
|
|
489
|
+
object.__setattr__(
|
|
490
|
+
self,
|
|
491
|
+
"network",
|
|
492
|
+
NetworkPolicy(
|
|
493
|
+
allow_hosts=hosts,
|
|
494
|
+
allow_ports=tuple(ports),
|
|
495
|
+
allow_http=net.allow_http,
|
|
496
|
+
allow_networks=net.allow_networks,
|
|
497
|
+
follow_redirects=net.follow_redirects,
|
|
498
|
+
max_redirects=net.max_redirects,
|
|
499
|
+
),
|
|
500
|
+
)
|
|
501
|
+
object.__setattr__(self, "base_url", self.base_url.rstrip("/"))
|
|
502
|
+
object.__setattr__(self, "headers", dict(self.headers))
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _host_matches(host: str, pattern: str) -> bool:
|
|
506
|
+
if pattern.startswith("*."):
|
|
507
|
+
suffix = pattern[1:]
|
|
508
|
+
return host.endswith(suffix) and len(host) > len(suffix)
|
|
509
|
+
return host == pattern
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def host_allowed(host: str, policy: NetworkPolicy) -> bool:
|
|
513
|
+
host = host.lower().rstrip(".")
|
|
514
|
+
return any(_host_matches(host, p) for p in policy.allow_hosts)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _validate_header_name(name: str, *, allow_reserved: bool) -> None:
|
|
518
|
+
if not _HEADER_NAME.match(name):
|
|
519
|
+
raise ConfigurationError(f"invalid header name {name!r}")
|
|
520
|
+
if not allow_reserved and name.lower() in _FORBIDDEN_STATIC_HEADERS:
|
|
521
|
+
raise ConfigurationError(
|
|
522
|
+
f"header {name!r} cannot be set statically (use auth= for credentials)"
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _validate_header_value(name: str, value: str) -> None:
|
|
527
|
+
if not isinstance(value, str) or any(c in value for c in "\r\n\x00"):
|
|
528
|
+
raise ConfigurationError(f"invalid value for header {name!r}")
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
# --------------------------------------------------------------------------- tools
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
535
|
+
class ToolSpec:
|
|
536
|
+
"""Fields shared by REST operations and Python tools."""
|
|
537
|
+
|
|
538
|
+
name: str
|
|
539
|
+
description: str
|
|
540
|
+
input: Mapping[str, Any] = field(default_factory=dict)
|
|
541
|
+
#: ``None`` is resolved in ``__post_init__``: REST operations infer it from the
|
|
542
|
+
#: HTTP method; Python tools must declare it.
|
|
543
|
+
effect: Effect = None # type: ignore[assignment]
|
|
544
|
+
output: Output = field(default_factory=Output)
|
|
545
|
+
bind: Mapping[str, Binding] = field(default_factory=dict)
|
|
546
|
+
required_scopes: tuple[str, ...] = ()
|
|
547
|
+
tags: tuple[str, ...] = ()
|
|
548
|
+
group: str | None = None
|
|
549
|
+
requires_approval: bool | None = None
|
|
550
|
+
rate_limit: RateLimit | None = None
|
|
551
|
+
|
|
552
|
+
def __post_init__(self) -> None:
|
|
553
|
+
if not _TOOL_NAME.match(self.name):
|
|
554
|
+
raise ConfigurationError(
|
|
555
|
+
f"invalid tool name {self.name!r}: use 1-64 letters, digits, '_' or '-', "
|
|
556
|
+
"starting with a letter"
|
|
557
|
+
)
|
|
558
|
+
if not isinstance(self.description, str) or not self.description.strip():
|
|
559
|
+
raise ConfigurationError(f"tool {self.name}: description is required")
|
|
560
|
+
if len(self.description) > _MAX_DESCRIPTION:
|
|
561
|
+
raise ConfigurationError(
|
|
562
|
+
f"tool {self.name}: description longer than {_MAX_DESCRIPTION} characters"
|
|
563
|
+
)
|
|
564
|
+
if not _unset(self.effect):
|
|
565
|
+
object.__setattr__(self, "effect", Effect(self.effect))
|
|
566
|
+
object.__setattr__(self, "bind", {k: parse_binding(v) for k, v in dict(self.bind).items()})
|
|
567
|
+
object.__setattr__(self, "required_scopes", tuple(self.required_scopes))
|
|
568
|
+
object.__setattr__(self, "tags", tuple(self.tags))
|
|
569
|
+
if self.group is not None and not _TOOL_NAME.match(self.group):
|
|
570
|
+
raise ConfigurationError(f"tool {self.name}: invalid group name {self.group!r}")
|
|
571
|
+
|
|
572
|
+
@property
|
|
573
|
+
def needs_approval(self) -> bool:
|
|
574
|
+
if self.requires_approval is not None:
|
|
575
|
+
return self.requires_approval
|
|
576
|
+
return self.effect is Effect.DESTRUCTIVE
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _unset(value: object) -> bool:
|
|
580
|
+
return value is None
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
_METHODS = frozenset({"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"})
|
|
584
|
+
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
|
585
|
+
#: Default effect per HTTP method when a contract does not declare one. A POST
|
|
586
|
+
#: that only reads (a search endpoint) must say ``effect="read"`` explicitly.
|
|
587
|
+
_METHOD_EFFECTS = {
|
|
588
|
+
"GET": Effect.READ,
|
|
589
|
+
"HEAD": Effect.READ,
|
|
590
|
+
"OPTIONS": Effect.READ,
|
|
591
|
+
"POST": Effect.WRITE,
|
|
592
|
+
"PUT": Effect.WRITE,
|
|
593
|
+
"PATCH": Effect.WRITE,
|
|
594
|
+
"DELETE": Effect.DESTRUCTIVE,
|
|
595
|
+
}
|
|
596
|
+
_PLACEHOLDER = re.compile(r"\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
600
|
+
class Operation(ToolSpec):
|
|
601
|
+
"""A REST-backed tool.
|
|
602
|
+
|
|
603
|
+
Argument placement: names that appear in ``path`` placeholders go into the
|
|
604
|
+
path (strictly encoded). Remaining arguments go into the query string for
|
|
605
|
+
GET/HEAD/DELETE/OPTIONS and into the body otherwise, unless ``query`` /
|
|
606
|
+
``body`` list names explicitly.
|
|
607
|
+
"""
|
|
608
|
+
|
|
609
|
+
service: Service
|
|
610
|
+
method: str = "GET"
|
|
611
|
+
path: str = "/"
|
|
612
|
+
query: tuple[str, ...] | None = None
|
|
613
|
+
body: tuple[str, ...] | None = None
|
|
614
|
+
body_format: Literal["json", "form"] = "json"
|
|
615
|
+
headers: Mapping[str, str] = field(default_factory=dict)
|
|
616
|
+
idempotency_header: str | None = None
|
|
617
|
+
pagination: Pagination | None = None
|
|
618
|
+
|
|
619
|
+
def __post_init__(self) -> None:
|
|
620
|
+
ToolSpec.__post_init__(self)
|
|
621
|
+
method = self.method.upper()
|
|
622
|
+
if method not in _METHODS:
|
|
623
|
+
raise ConfigurationError(f"tool {self.name}: unsupported HTTP method {self.method!r}")
|
|
624
|
+
object.__setattr__(self, "method", method)
|
|
625
|
+
inferred = _METHOD_EFFECTS[method]
|
|
626
|
+
if _unset(self.effect):
|
|
627
|
+
object.__setattr__(self, "effect", inferred)
|
|
628
|
+
elif method == "DELETE" and self.effect is Effect.READ:
|
|
629
|
+
raise ConfigurationError(
|
|
630
|
+
f"tool {self.name}: a DELETE operation cannot be effect='read'"
|
|
631
|
+
)
|
|
632
|
+
if not self.path.startswith("/") or "?" in self.path or "#" in self.path:
|
|
633
|
+
raise ConfigurationError(
|
|
634
|
+
f"tool {self.name}: path must start with '/' and contain no query or fragment"
|
|
635
|
+
)
|
|
636
|
+
if ".." in self.path.split("/"):
|
|
637
|
+
raise ConfigurationError(f"tool {self.name}: path must not contain '..'")
|
|
638
|
+
if self.body_format not in ("json", "form"):
|
|
639
|
+
raise ConfigurationError(f"tool {self.name}: body_format must be json or form")
|
|
640
|
+
for name, value in self.headers.items():
|
|
641
|
+
_validate_header_name(name, allow_reserved=False)
|
|
642
|
+
_validate_header_value(name, value)
|
|
643
|
+
if self.idempotency_header is not None:
|
|
644
|
+
_validate_header_name(self.idempotency_header, allow_reserved=False)
|
|
645
|
+
if self.pagination is not None and self.effect is not Effect.READ:
|
|
646
|
+
raise ConfigurationError(f"tool {self.name}: pagination is only for read tools")
|
|
647
|
+
object.__setattr__(self, "headers", dict(self.headers))
|
|
648
|
+
if self.query is not None:
|
|
649
|
+
object.__setattr__(self, "query", tuple(self.query))
|
|
650
|
+
if self.body is not None:
|
|
651
|
+
object.__setattr__(self, "body", tuple(self.body))
|
|
652
|
+
|
|
653
|
+
@property
|
|
654
|
+
def path_params(self) -> tuple[str, ...]:
|
|
655
|
+
return tuple(_PLACEHOLDER.findall(self.path))
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
659
|
+
class PythonTool(ToolSpec):
|
|
660
|
+
"""A tool implemented by a trusted Python function.
|
|
661
|
+
|
|
662
|
+
Toolbound does **not** sandbox the handler. It runs with your application's
|
|
663
|
+
full privileges. Toolbound validates its input, enforces authorization,
|
|
664
|
+
approval, rate limits and timeouts, and shapes its output, exactly like a
|
|
665
|
+
REST operation.
|
|
666
|
+
|
|
667
|
+
The handler receives validated arguments (and bound values) as keyword
|
|
668
|
+
arguments. If it declares a parameter named ``ctx``, the trusted
|
|
669
|
+
:class:`ExecutionContext` is passed there. Sync handlers run in a worker
|
|
670
|
+
thread.
|
|
671
|
+
"""
|
|
672
|
+
|
|
673
|
+
handler: Callable[..., Any]
|
|
674
|
+
timeout: float = 30.0
|
|
675
|
+
|
|
676
|
+
def __post_init__(self) -> None:
|
|
677
|
+
ToolSpec.__post_init__(self)
|
|
678
|
+
if _unset(self.effect):
|
|
679
|
+
raise ConfigurationError(
|
|
680
|
+
f"tool {self.name}: Python tools must declare effect='read', 'write' or "
|
|
681
|
+
"'destructive' (Toolbound cannot tell what a handler does)"
|
|
682
|
+
)
|
|
683
|
+
if not callable(self.handler):
|
|
684
|
+
raise ConfigurationError(f"tool {self.name}: handler must be callable")
|
|
685
|
+
if self.timeout <= 0:
|
|
686
|
+
raise ConfigurationError(f"tool {self.name}: timeout must be positive")
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
Tool = Operation | PythonTool
|