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,314 @@
1
+ """Human approval bound to one exact call.
2
+
3
+ Flow:
4
+
5
+ 1. A call that needs approval returns ``approval_required`` with a
6
+ :class:`PendingApproval`. Its :attr:`~PendingApproval.token` is an
7
+ **encrypted, authenticated approval request** issued by the gateway. It
8
+ carries the tool, the validated arguments and a digest of the trusted bound
9
+ values, actor and tenant. Store or round-trip the token freely (database,
10
+ browser, LangGraph interrupt): it cannot be altered or forged, and its
11
+ contents cannot be read without the gateway secret.
12
+ 2. Host code shows the request to a human (:meth:`Gateway.pending_approval`
13
+ rebuilds the display data from a token) and, on approval, calls
14
+ :meth:`Gateway.grant` or :meth:`Gateway.resume` **with the token**. The
15
+ fields of a ``PendingApproval`` object are never trusted, only its token.
16
+ 3. The grant is an HMAC-signed, single-use token committing to the request id
17
+ and digest. :meth:`Gateway.call` verifies it, recomputes the digest from the
18
+ live call and the live :class:`ExecutionContext`, and claims the request id
19
+ in the :class:`~toolbound.core.ports.StateStore` so it runs at most once.
20
+
21
+ Grants are passed to :meth:`Gateway.call` as a keyword argument by host code,
22
+ never through tool arguments, so a model cannot approve its own actions.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import secrets
29
+ import time
30
+ import unicodedata
31
+ from collections.abc import Mapping
32
+ from dataclasses import dataclass, field
33
+ from typing import Any
34
+
35
+ from .context import ExecutionContext
36
+ from .contracts import Effect
37
+ from .errors import ApprovalInvalid
38
+ from .ports import StateStore
39
+ from .signing import Signer, canonical_digest
40
+ from .state import claim_or_fail
41
+
42
+ _GRANT_PREFIX = "tba2"
43
+ _REQUEST_PREFIX = "tbr1"
44
+ _MAX_REQUEST_TOKEN = 512 * 1024
45
+ _SUMMARY_LIMIT = 2000
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class PendingApproval:
50
+ """A call waiting for a human decision.
51
+
52
+ Show the human :attr:`arguments`, :attr:`bound` and :attr:`target` (or at
53
+ least :attr:`summary`, checking :attr:`summary_truncated`). Persist or send
54
+ :attr:`token`; :meth:`to_dict` is JSON-safe and includes it.
55
+ """
56
+
57
+ id: str
58
+ tool: str
59
+ effect: Effect
60
+ actor: str
61
+ tenant: str | None
62
+ arguments: Mapping[str, Any]
63
+ bound: Mapping[str, Any]
64
+ summary: str
65
+ digest: str
66
+ expires_at: float
67
+ token: str = ""
68
+ target: Mapping[str, Any] = field(default_factory=dict)
69
+ summary_truncated: bool = False
70
+
71
+ def to_dict(self) -> dict[str, Any]:
72
+ return {
73
+ "id": self.id,
74
+ "tool": self.tool,
75
+ "effect": self.effect.value,
76
+ "actor": self.actor,
77
+ "tenant": self.tenant,
78
+ "target": dict(self.target),
79
+ "arguments": dict(self.arguments),
80
+ "bound": dict(self.bound),
81
+ "summary": self.summary,
82
+ "summary_truncated": self.summary_truncated,
83
+ "expires_at": self.expires_at,
84
+ "token": self.token,
85
+ }
86
+
87
+
88
+ def approval_digest(
89
+ *,
90
+ tool: str,
91
+ fingerprint: str,
92
+ ctx: ExecutionContext,
93
+ arguments: Mapping[str, Any],
94
+ bound: Mapping[str, Any],
95
+ ) -> str:
96
+ return canonical_digest(
97
+ {
98
+ "tool": tool,
99
+ "contract": fingerprint,
100
+ "actor": ctx.actor,
101
+ "tenant": ctx.tenant,
102
+ "arguments": dict(arguments),
103
+ "bound": dict(bound),
104
+ }
105
+ )
106
+
107
+
108
+ def _display(value: Any) -> str:
109
+ """JSON for humans: control, format and bidi characters are escaped."""
110
+ text = json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
111
+ return "".join(
112
+ f"\\u{ord(c):04x}" if unicodedata.category(c) in ("Cc", "Cf", "Zl", "Zp") else c
113
+ for c in text
114
+ )
115
+
116
+
117
+ def _summary(
118
+ tool: str,
119
+ effect: Effect,
120
+ target: Mapping[str, Any],
121
+ ctx: ExecutionContext,
122
+ arguments: Mapping[str, Any],
123
+ bound: Mapping[str, Any],
124
+ ) -> tuple[str, bool]:
125
+ if target.get("kind") == "rest":
126
+ where = f"{target.get('method')} {target.get('url')}"
127
+ else:
128
+ where = f"Python tool {tool}"
129
+ who = f"actor {_display(ctx.actor)}" + (
130
+ f", tenant {_display(ctx.tenant)}" if ctx.tenant is not None else ""
131
+ )
132
+ text = (
133
+ f"[{effect.value.upper()}] {where} (tool {tool}) for {who}. "
134
+ f"Arguments: {_display(dict(arguments))}. Trusted values: {_display(dict(bound))}."
135
+ )
136
+ if len(text) <= _SUMMARY_LIMIT:
137
+ return text, False
138
+ hidden = len(text) - _SUMMARY_LIMIT
139
+ return (
140
+ text[:_SUMMARY_LIMIT]
141
+ + f" … [{hidden} more characters not shown: review the full arguments before approving]",
142
+ True,
143
+ )
144
+
145
+
146
+ class ApprovalManager:
147
+ """Issues approval requests and grants, and verifies grants exactly once."""
148
+
149
+ def __init__(self, signer: Signer, store: StateStore, *, ttl_seconds: float = 600) -> None:
150
+ if not 0 < ttl_seconds <= MAX_APPROVAL_TTL:
151
+ raise ValueError(f"approval_ttl must be between 0 and {MAX_APPROVAL_TTL} seconds")
152
+ self._signer = signer
153
+ self._store = store
154
+ self.ttl_seconds = ttl_seconds
155
+
156
+ def create(
157
+ self,
158
+ *,
159
+ ctx: ExecutionContext,
160
+ tool: str,
161
+ effect: Effect,
162
+ fingerprint: str,
163
+ arguments: Mapping[str, Any],
164
+ bound: Mapping[str, Any],
165
+ target: Mapping[str, Any] | None = None,
166
+ ) -> PendingApproval:
167
+ digest = approval_digest(
168
+ tool=tool, fingerprint=fingerprint, ctx=ctx, arguments=arguments, bound=bound
169
+ )
170
+ request_id = secrets.token_urlsafe(16)
171
+ expires_at = time.time() + self.ttl_seconds
172
+ target_map = dict(target or {})
173
+ summary, truncated = _summary(tool, effect, target_map, ctx, arguments, bound)
174
+ token = self._signer.seal(
175
+ _REQUEST_PREFIX,
176
+ {
177
+ "i": request_id,
178
+ "d": digest,
179
+ "t": tool,
180
+ "e": effect.value,
181
+ "a": dict(arguments),
182
+ "b": dict(bound),
183
+ "u": ctx.actor,
184
+ "n": ctx.tenant,
185
+ "g": target_map,
186
+ "x": expires_at,
187
+ },
188
+ self.ttl_seconds,
189
+ )
190
+ return PendingApproval(
191
+ id=request_id,
192
+ tool=tool,
193
+ effect=effect,
194
+ actor=ctx.actor,
195
+ tenant=ctx.tenant,
196
+ arguments=dict(arguments),
197
+ bound=dict(bound),
198
+ summary=summary,
199
+ digest=digest,
200
+ expires_at=expires_at,
201
+ token=token,
202
+ target=target_map,
203
+ summary_truncated=truncated,
204
+ )
205
+
206
+ def open(self, request: PendingApproval | str) -> PendingApproval:
207
+ """Rebuild a :class:`PendingApproval` from its token. Raises :class:`ApprovalInvalid`."""
208
+ token = request.token if isinstance(request, PendingApproval) else request
209
+ payload = self._signer.unseal(_REQUEST_PREFIX, token, max_length=_MAX_REQUEST_TOKEN)
210
+ if payload is None:
211
+ raise ApprovalInvalid("approval request is invalid, forged or expired")
212
+ try:
213
+ effect = Effect(payload["e"])
214
+ arguments = dict(payload["a"])
215
+ bound = dict(payload["b"])
216
+ target = dict(payload["g"])
217
+ tool, actor, tenant = str(payload["t"]), str(payload["u"]), payload["n"]
218
+ request_id, digest = str(payload["i"]), str(payload["d"])
219
+ expires_at = float(payload["x"])
220
+ except (KeyError, TypeError, ValueError):
221
+ raise ApprovalInvalid("approval request is malformed") from None
222
+ ctx = ExecutionContext(actor=actor, tenant=tenant)
223
+ summary, truncated = _summary(tool, effect, target, ctx, arguments, bound)
224
+ return PendingApproval(
225
+ id=request_id,
226
+ tool=tool,
227
+ effect=effect,
228
+ actor=actor,
229
+ tenant=tenant,
230
+ arguments=arguments,
231
+ bound=bound,
232
+ summary=summary,
233
+ digest=digest,
234
+ expires_at=expires_at,
235
+ token=str(token),
236
+ target=target,
237
+ summary_truncated=truncated,
238
+ )
239
+
240
+ def grant(self, request: PendingApproval | str, *, approved_by: str) -> str:
241
+ """Issue a single-use grant for a gateway-issued request. Host code only."""
242
+ if not approved_by or not isinstance(approved_by, str):
243
+ raise ValueError("approved_by is required for the audit trail")
244
+ pending = self.open(request) # verifies the token; object fields are ignored
245
+ remaining = pending.expires_at - time.time()
246
+ if remaining <= 0:
247
+ raise ApprovalInvalid("approval request has expired")
248
+ return self._signer.sign(
249
+ _GRANT_PREFIX, {"i": pending.id, "d": pending.digest, "by": approved_by}, remaining
250
+ )
251
+
252
+ def verify(
253
+ self,
254
+ token: str,
255
+ *,
256
+ ctx: ExecutionContext,
257
+ tool: str,
258
+ fingerprint: str,
259
+ arguments: Mapping[str, Any],
260
+ bound: Mapping[str, Any],
261
+ ) -> dict[str, Any]:
262
+ """Check a grant against the live call without using it up."""
263
+ payload = self._signer.verify(_GRANT_PREFIX, token)
264
+ if payload is None:
265
+ raise ApprovalInvalid("approval is invalid or has expired")
266
+ expected = approval_digest(
267
+ tool=tool, fingerprint=fingerprint, ctx=ctx, arguments=arguments, bound=bound
268
+ )
269
+ if payload.get("d") != expected:
270
+ raise ApprovalInvalid(
271
+ "approval does not match this call (tool, arguments, actor, tenant or "
272
+ "trusted values changed)"
273
+ )
274
+ return payload
275
+
276
+ async def claim(self, payload: Mapping[str, Any], ctx: ExecutionContext) -> None:
277
+ """Use a verified grant (once). Called immediately before execution."""
278
+ if not await claim_or_fail(self._store, _claim_key(ctx, str(payload["i"])), _CLAIM_TTL):
279
+ raise ApprovalInvalid("approval has already been used or was rejected")
280
+
281
+ async def deny(self, request: PendingApproval | str) -> PendingApproval:
282
+ """Record a refusal: the request can never be granted afterwards."""
283
+ pending = self.open(request)
284
+ ctx = ExecutionContext(actor=pending.actor, tenant=pending.tenant)
285
+ await claim_or_fail(self._store, _claim_key(ctx, pending.id), _CLAIM_TTL)
286
+ return pending
287
+
288
+ async def consume(
289
+ self,
290
+ token: str,
291
+ *,
292
+ ctx: ExecutionContext,
293
+ tool: str,
294
+ fingerprint: str,
295
+ arguments: Mapping[str, Any],
296
+ bound: Mapping[str, Any],
297
+ ) -> dict[str, Any]:
298
+ """Verify and claim in one step."""
299
+ payload = self.verify(
300
+ token, ctx=ctx, tool=tool, fingerprint=fingerprint, arguments=arguments, bound=bound
301
+ )
302
+ await self.claim(payload, ctx)
303
+ return payload
304
+
305
+
306
+ #: Claims must outlive any grant. Grants never live longer than the request TTL, which
307
+ #: is capped at ``MAX_APPROVAL_TTL``, so a fixed claim TTL is safe even when workers
308
+ #: are configured with different ``approval_ttl`` values.
309
+ MAX_APPROVAL_TTL = 7 * 24 * 3600
310
+ _CLAIM_TTL = MAX_APPROVAL_TTL + 3600
311
+
312
+
313
+ def _claim_key(ctx: ExecutionContext, request_id: str) -> str:
314
+ return f"approval:{canonical_digest([ctx.actor, ctx.tenant])[:16]}:{request_id}"
@@ -0,0 +1,42 @@
1
+ """Token estimation.
2
+
3
+ The default estimator is deterministic and dependency-free. It is an
4
+ *approximation*, deliberately conservative (it tends to over-count) so budgets
5
+ are rarely exceeded. Plug in a real tokenizer through the
6
+ :class:`~toolbound.core.ports.TokenEstimator` port when exact counts matter
7
+ (see :class:`toolbound.estimators.TiktokenEstimator`).
8
+
9
+ Measured accuracy against a real tokenizer is published in ``docs/benchmarks.md``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import math
16
+ from typing import Any
17
+
18
+
19
+ class ByteEstimator:
20
+ """Estimate tokens as ``ceil(utf8_bytes / bytes_per_token)``.
21
+
22
+ The default of 3.0 bytes per token over-estimates typical English JSON
23
+ (closer to ~3.5-4.5 bytes/token for modern tokenizers) and stays
24
+ conservative for non-Latin scripts such as Persian or Arabic, which use 2
25
+ bytes per character in UTF-8 and often tokenize less efficiently.
26
+ """
27
+
28
+ def __init__(self, bytes_per_token: float = 3.0) -> None:
29
+ if bytes_per_token <= 0:
30
+ raise ValueError("bytes_per_token must be positive")
31
+ self.bytes_per_token = bytes_per_token
32
+
33
+ def count(self, text: str) -> int:
34
+ return math.ceil(len(text.encode("utf-8")) / self.bytes_per_token)
35
+
36
+ def __repr__(self) -> str:
37
+ return f"ByteEstimator(bytes_per_token={self.bytes_per_token})"
38
+
39
+
40
+ def to_json(value: Any) -> str:
41
+ """Compact, deterministic JSON used for both budgeting and model output."""
42
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=False)
@@ -0,0 +1,56 @@
1
+ """Trusted execution context.
2
+
3
+ The :class:`ExecutionContext` is created by **your application** after it has
4
+ authenticated the end user. It is never built from model output. Everything
5
+ security-relevant (who is acting, for which tenant, with which scopes) comes from
6
+ here, not from tool arguments.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import uuid
12
+ from collections.abc import Mapping
13
+ from dataclasses import dataclass, field
14
+ from types import MappingProxyType
15
+ from typing import Any
16
+
17
+
18
+ def _new_request_id() -> str:
19
+ return uuid.uuid4().hex
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class ExecutionContext:
24
+ """Identity and environment for one agent turn, supplied by trusted code.
25
+
26
+ Args:
27
+ actor: Stable identifier of the authenticated user or service account.
28
+ tenant: Optional tenant / organization identifier for multi-tenant apps.
29
+ scopes: Permissions granted to the actor (checked against
30
+ ``Tool.required_scopes``).
31
+ attributes: Extra trusted values that contracts may bind into requests
32
+ (for example ``{"repo_owner": "acme"}``). Never put secrets here;
33
+ use a :class:`~toolbound.CredentialProvider` instead.
34
+ request_id: Correlation id for logs and approvals. Generated if omitted.
35
+ """
36
+
37
+ actor: str
38
+ tenant: str | None = None
39
+ scopes: frozenset[str] = frozenset()
40
+ attributes: Mapping[str, Any] = field(default_factory=dict)
41
+ request_id: str = field(default_factory=_new_request_id)
42
+
43
+ def __post_init__(self) -> None:
44
+ if not isinstance(self.actor, str) or not self.actor:
45
+ raise ValueError("ExecutionContext.actor must be a non-empty string")
46
+ object.__setattr__(self, "scopes", frozenset(self.scopes))
47
+ object.__setattr__(self, "attributes", MappingProxyType(dict(self.attributes)))
48
+
49
+ def __repr__(self) -> str:
50
+ # Attribute values are deliberately not printed: they may be sensitive.
51
+ keys = ", ".join(sorted(self.attributes))
52
+ return (
53
+ f"ExecutionContext(actor={self.actor!r}, tenant={self.tenant!r}, "
54
+ f"scopes={sorted(self.scopes)!r}, attributes=[{keys}], "
55
+ f"request_id={self.request_id!r})"
56
+ )