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.
@@ -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
+ )
@@ -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
@@ -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
+ ...