idside 0.1.0.dev0__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.
- idside/__about__.py +5 -0
- idside/__init__.py +158 -0
- idside/_destination.py +165 -0
- idside/_guard.py +573 -0
- idside/_jcs.py +130 -0
- idside/_log.py +163 -0
- idside/_routing.py +280 -0
- idside/_transport.py +831 -0
- idside/adapters/__init__.py +110 -0
- idside/adapters/_call_options.py +859 -0
- idside/adapters/base.py +462 -0
- idside/adapters/gemini.py +89 -0
- idside/consent-options.json +207 -0
- idside/consent.py +1580 -0
- idside/exceptions.py +481 -0
- idside/options.py +297 -0
- idside/providers/__init__.py +252 -0
- idside/providers/anthropic.json +108 -0
- idside/providers/deepseek.json +108 -0
- idside/providers/gemini.json +130 -0
- idside/providers/mistral.json +195 -0
- idside/providers/openai.json +217 -0
- idside/py.typed +0 -0
- idside/verify.py +776 -0
- idside-0.1.0.dev0.dist-info/METADATA +473 -0
- idside-0.1.0.dev0.dist-info/RECORD +29 -0
- idside-0.1.0.dev0.dist-info/WHEEL +4 -0
- idside-0.1.0.dev0.dist-info/licenses/LICENSE +350 -0
- idside-0.1.0.dev0.dist-info/licenses/NOTICE +29 -0
idside/_guard.py
ADDED
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
# Patents: https://sdk.idside.eu/patents
|
|
2
|
+
# This software may be covered by the patents and applications listed there.
|
|
3
|
+
|
|
4
|
+
"""The interception net: a blind guard on the provider client's HTTP transport.
|
|
5
|
+
|
|
6
|
+
Patching the methods a descriptor declares can never be exhaustive. A provider
|
|
7
|
+
ships a new endpoint, a helper posts to the API without going through the method
|
|
8
|
+
the SDK wrapped, a resource nobody thought to declare (``embeddings``,
|
|
9
|
+
``images``, ``audio``, ``moderations``, ``batches``, ``files``, ``beta.*``) sits
|
|
10
|
+
on the same client. Each of those reaches the provider with no authorize, no ACT
|
|
11
|
+
token and no ledger entry — and, worse than failing, does it silently.
|
|
12
|
+
|
|
13
|
+
So the last line is drawn where every request must pass whatever route it took:
|
|
14
|
+
the client's own HTTP transport. A body-bearing request passes only when its ACT
|
|
15
|
+
token matches the private authorization context for this wrapped client and the
|
|
16
|
+
same method/path (including retries and streaming continuation). Any other such
|
|
17
|
+
request is refused before it leaves the process.
|
|
18
|
+
|
|
19
|
+
Every transport the client owns, and recognised by shape rather than by name.
|
|
20
|
+
Two mistakes are possible here and both have been made. The first is counting
|
|
21
|
+
one transport: an HTTP client library splits blocking and async into two
|
|
22
|
+
unrelated classes — the async one is no subclass of the blocking one, and its
|
|
23
|
+
``send`` is a coroutine — and a single client may own both at once, as one
|
|
24
|
+
``google-genai`` ``Client`` does (``_httpx_client`` and ``_async_httpx_client``,
|
|
25
|
+
with the async surface at ``client.aio``). Stopping at the first match left the
|
|
26
|
+
other route wide open.
|
|
27
|
+
|
|
28
|
+
The second is asking which distribution shipped the class. ``isinstance`` against
|
|
29
|
+
``httpx``'s two classes answered that question, not the one that was meant, and
|
|
30
|
+
the answer changed underneath it: ``openai>=3`` requires ``httpx2``, a separate
|
|
31
|
+
distribution whose clients are not ``httpx`` clients and never will be. Under it
|
|
32
|
+
nothing matched, and the net reported that there had been nothing to guard about
|
|
33
|
+
a client holding a live route to the provider. A transport is now anything
|
|
34
|
+
carrying the shape of one — ``send``, ``build_request``, ``base_url``, read off
|
|
35
|
+
the class — whoever packaged it.
|
|
36
|
+
|
|
37
|
+
**This guard is a detector, not an enforcer, and it is deliberately narrow.** It
|
|
38
|
+
reads the request's HTTP method, the URL's path and origin (scheme, host, port)
|
|
39
|
+
and one named header, comparing them locally with private authorization
|
|
40
|
+
provenance: the token must be the one this wrapped client was just given, for
|
|
41
|
+
this route, bound for the origin the client was wrapped with. Never the body,
|
|
42
|
+
the query string or any other header. It hashes nothing, logs nothing and
|
|
43
|
+
sends nothing anywhere — it is on the AI request path of an application whose
|
|
44
|
+
whole premise is that we do not see the request.
|
|
45
|
+
|
|
46
|
+
An authorized request is also sent with ``follow_redirects=False``. The provider
|
|
47
|
+
libraries build their HTTP clients to follow redirects, and ``httpx`` follows
|
|
48
|
+
them below ``send``, where no guard runs: a 307 or 308 re-sent the body, the ACT
|
|
49
|
+
token and the API key to wherever it pointed, and the guard only ever saw the
|
|
50
|
+
first hop. A redirect now comes back to the caller as the provider's answer.
|
|
51
|
+
|
|
52
|
+
Which makes what a transport's ``send`` accepts a fact about whether the client
|
|
53
|
+
can be guarded at all, and ``unredirectable_route`` is where that is asked —
|
|
54
|
+
before ``wrap()`` patches anything. Recognising transports by shape says nothing
|
|
55
|
+
about their signature, and a transport of the right shape whose ``send`` will
|
|
56
|
+
not take the keyword left the only two outcomes that are both unacceptable:
|
|
57
|
+
sending anyway with redirects followed, or a ``TypeError`` raised *after* the
|
|
58
|
+
cloud authorized the call and appended the ledger — a proof of a request that
|
|
59
|
+
never left the process, failing where nothing names the cause, while every
|
|
60
|
+
bodyless call kept working.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
from __future__ import annotations
|
|
64
|
+
|
|
65
|
+
import functools
|
|
66
|
+
import inspect
|
|
67
|
+
import threading
|
|
68
|
+
from collections import deque
|
|
69
|
+
from collections.abc import Callable
|
|
70
|
+
from typing import Any, Literal, NamedTuple, Protocol
|
|
71
|
+
|
|
72
|
+
from idside._destination import Origin, origin_of
|
|
73
|
+
from idside.exceptions import UnenforcedCallError
|
|
74
|
+
from idside.providers import ProviderDescriptor
|
|
75
|
+
|
|
76
|
+
#: What a wrapped client's predicate is asked: the ACT header's value, the
|
|
77
|
+
#: method, the URL path, and the origin the request is bound for.
|
|
78
|
+
Provenance = Callable[[str, str, str, Origin | None], bool]
|
|
79
|
+
|
|
80
|
+
__all__ = ["install", "unredirectable_route"]
|
|
81
|
+
|
|
82
|
+
#: The one keyword the net adds to a send it lets through.
|
|
83
|
+
_NO_REDIRECTS = "follow_redirects"
|
|
84
|
+
|
|
85
|
+
#: Attribute a predicate may carry: a callable answering whether the client it
|
|
86
|
+
#: belongs to still exists. A predicate without one is taken to live forever.
|
|
87
|
+
ALIVE = "__idside_alive__"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
#: Serialises every WRITE to a transport's predicate list. The list is shared by
|
|
91
|
+
#: a client and every client derived from it, and those are used from several
|
|
92
|
+
#: threads: an unlocked prune computed on one thread and written back after
|
|
93
|
+
#: another thread's ``append`` dropped that predicate, leaving a live derived
|
|
94
|
+
#: client whose every authorized request was refused.
|
|
95
|
+
#:
|
|
96
|
+
#: One lock for the whole process, so anything that takes it on the request path
|
|
97
|
+
#: serialises every wrapped client in it against every other. Reading does not
|
|
98
|
+
#: take it — see :func:`_live`.
|
|
99
|
+
_PROVENANCE_LOCK = threading.Lock()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _forever() -> bool:
|
|
103
|
+
"""The aliveness of a predicate that declares none: it outlives the list."""
|
|
104
|
+
return True
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _is_alive(check: Provenance) -> bool:
|
|
108
|
+
"""Whether the client this predicate belongs to still exists."""
|
|
109
|
+
return getattr(check, ALIVE, _forever)()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _compact(provenances: list[Provenance]) -> None:
|
|
113
|
+
"""Drop, in place, the predicates of clients that no longer exist.
|
|
114
|
+
|
|
115
|
+
The list lives on a transport that derived clients share with their parent,
|
|
116
|
+
so one predicate is added per ``with_options()`` — and a derived client made
|
|
117
|
+
per request would otherwise leave one behind per request, forever.
|
|
118
|
+
|
|
119
|
+
Under the lock, because it writes and ``_guard_one`` appends.
|
|
120
|
+
"""
|
|
121
|
+
with _PROVENANCE_LOCK:
|
|
122
|
+
provenances[:] = [check for check in provenances if _is_alive(check)]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _live(provenances: list[Provenance]) -> tuple[Provenance, ...]:
|
|
126
|
+
"""The predicates still standing — rewriting the list only if one has gone.
|
|
127
|
+
|
|
128
|
+
This runs on every body-bearing request of every wrapped client, so what it
|
|
129
|
+
does *not* do is the point. It used to take the process-wide lock and
|
|
130
|
+
rebuild the whole list, on every request, whether or not anything had died —
|
|
131
|
+
and nothing usually has, since a predicate dies only when a derived client
|
|
132
|
+
is collected. A server calling the AI from several threads was serialising
|
|
133
|
+
its own outgoing requests against each other to do no work.
|
|
134
|
+
|
|
135
|
+
The snapshot is taken without the lock and cannot tear: ``tuple(list)`` is
|
|
136
|
+
one C-level copy that runs no Python code, so it sees the list either before
|
|
137
|
+
or after another thread's write, never half-way through one. Iterating the
|
|
138
|
+
list itself would not be safe — a concurrent compaction shifts the indices
|
|
139
|
+
under an iterator, which is how a live derived client loses its predicate
|
|
140
|
+
mid-check and has its authorized request refused.
|
|
141
|
+
|
|
142
|
+
A dead predicate left in the list for one more request is harmless: its
|
|
143
|
+
scope is gone, so it answers ``False``, which is exactly what it answered
|
|
144
|
+
while alive for any call that was not its own. Compaction is therefore a
|
|
145
|
+
memory concern and nothing else, and it is done once a death is seen rather
|
|
146
|
+
than on the chance of one.
|
|
147
|
+
"""
|
|
148
|
+
snapshot = tuple(provenances)
|
|
149
|
+
live = tuple(check for check in snapshot if _is_alive(check))
|
|
150
|
+
if len(live) != len(snapshot):
|
|
151
|
+
_compact(provenances)
|
|
152
|
+
return live
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
#: Marker on a patched ``send`` so a second ``wrap()`` never stacks guards.
|
|
156
|
+
_GUARD_MARKER = "__idside_transport_guarded__"
|
|
157
|
+
|
|
158
|
+
#: Methods allowed through unenforced. This is where the line is drawn: they
|
|
159
|
+
#: carry no prompt to infer on, they are the provider's management surface
|
|
160
|
+
#: (``models.list``, ``files.delete``, polling a response id), and
|
|
161
|
+
#: ``/v1/authorize`` needs a ``model`` they do not have. A body-bearing method
|
|
162
|
+
#: is an inference call until proven otherwise.
|
|
163
|
+
_BODYLESS_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "DELETE"})
|
|
164
|
+
|
|
165
|
+
#: How far into a client's object graph to look for its transport, and how many
|
|
166
|
+
#: objects to touch at most. Both are bounds, not tuning: an unbounded walk over
|
|
167
|
+
#: an arbitrary third-party object graph is a hang waiting to happen, on the
|
|
168
|
+
#: request path.
|
|
169
|
+
#:
|
|
170
|
+
#: They are not the same KIND of bound, and only one of them is reported. The
|
|
171
|
+
#: depth is the declared scope of the search — "a transport a client owns is
|
|
172
|
+
#: within three hops of it" — uniform for every client, and true of every client
|
|
173
|
+
#: this SDK ships support for. The node budget is a give-up: whether it is hit,
|
|
174
|
+
#: and what is left unseen when it is, depends on how many attributes happened
|
|
175
|
+
#: to be materialised on this particular instance. The same client can then be
|
|
176
|
+
#: fully guarded in one process and half-guarded in the next, which is exactly
|
|
177
|
+
#: the kind of fact ``install()`` must not round off to ``True``.
|
|
178
|
+
_MAX_DEPTH = 3
|
|
179
|
+
_MAX_NODES = 256
|
|
180
|
+
|
|
181
|
+
#: The three marks of an HTTP client, looked for on the CLASS and never on the
|
|
182
|
+
#: instance.
|
|
183
|
+
#:
|
|
184
|
+
#: ``isinstance(node, httpx.Client | httpx.AsyncClient)`` did not ask "is this an
|
|
185
|
+
#: HTTP client". It asked which *distribution* shipped the class, and that
|
|
186
|
+
#: question had one answer for years and now has two: ``openai>=3`` requires
|
|
187
|
+
#: ``httpx2``, a separate distribution whose clients are not ``httpx`` clients
|
|
188
|
+
#: and never will be. Under it nothing matched, ``install()`` answered ``False``,
|
|
189
|
+
#: and the journal printed ``net=false`` — "there was nothing to guard" — about a
|
|
190
|
+
#: client holding a live route to the provider. Silent, and reported as
|
|
191
|
+
#: reassuring.
|
|
192
|
+
#:
|
|
193
|
+
#: So the question asked is the one that was meant: does this object have the
|
|
194
|
+
#: shape of an HTTP client. All three marks, because any one alone is common —
|
|
195
|
+
#: a mailer sends, an SDK shim has a ``base_url`` — and a false positive here is
|
|
196
|
+
#: not a harmless extra guard but a working object broken on the request path.
|
|
197
|
+
#:
|
|
198
|
+
#: Read off ``type(node)``, which is inert: ``getattr`` on a class hands back the
|
|
199
|
+
#: function or the ``property`` OBJECT without running either. That is the same
|
|
200
|
+
#: reason the walk reads ``__dict__`` and never an instance attribute.
|
|
201
|
+
_TRANSPORT_MARKS = ("send", "build_request", "base_url")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class _Transport(Protocol):
|
|
205
|
+
"""What the net recognises, stated as a shape rather than as a class.
|
|
206
|
+
|
|
207
|
+
Never used to *construct* anything — these objects only ever arrive out of a
|
|
208
|
+
third-party client's ``__dict__``. It is here so the annotations below say
|
|
209
|
+
what they mean.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
def send(self, request: Any, /, *args: Any, **kwargs: Any) -> Any: ...
|
|
213
|
+
|
|
214
|
+
def build_request(self, *args: Any, **kwargs: Any) -> Any: ...
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def base_url(self) -> Any: ...
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _is_transport(node: Any) -> bool:
|
|
221
|
+
"""Whether ``node`` is an HTTP client, whoever packaged it."""
|
|
222
|
+
cls = type(node)
|
|
223
|
+
try:
|
|
224
|
+
marks = [getattr(cls, name, None) for name in _TRANSPORT_MARKS]
|
|
225
|
+
except Exception:
|
|
226
|
+
# A metaclass ``__getattr__`` is arbitrary code and may raise. Something
|
|
227
|
+
# that will not answer what it is does not get patched.
|
|
228
|
+
return False
|
|
229
|
+
send, build_request, base_url = marks
|
|
230
|
+
return callable(send) and callable(build_request) and base_url is not None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _sends_coroutines(node: Any) -> bool:
|
|
234
|
+
"""Whether this transport's ``send`` must be awaited.
|
|
235
|
+
|
|
236
|
+
Asked of the CLASS, not of ``node.send``: by the time a second ``wrap()``
|
|
237
|
+
comes past, the instance attribute is this module's own wrapper, and the
|
|
238
|
+
shape to preserve is the one the transport was written in.
|
|
239
|
+
"""
|
|
240
|
+
return inspect.iscoroutinefunction(getattr(type(node), "send", None))
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
#: What ``install()`` answers, and what the journal's ``net=`` repeats verbatim.
|
|
244
|
+
#: ``True`` and ``False`` keep the meaning they always had; ``"partial"`` is the
|
|
245
|
+
#: case that used to be reported as one of them and is neither.
|
|
246
|
+
NetState = bool | Literal["partial"]
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class _Scan(NamedTuple):
|
|
250
|
+
"""Every transport one walk found, and whether that walk got to finish.
|
|
251
|
+
|
|
252
|
+
The second field is the whole point: a list of transports says nothing about
|
|
253
|
+
the ones the search never reached, and a caller that cannot tell the two
|
|
254
|
+
apart can only guess in the reassuring direction.
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
transports: list[_Transport]
|
|
258
|
+
complete: bool
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _find_transports(client: Any) -> _Scan:
|
|
262
|
+
"""Breadth-first search for EVERY HTTP transport a client sends on.
|
|
263
|
+
|
|
264
|
+
Reads ``__dict__`` only, never ``getattr`` on a guessed attribute name: a
|
|
265
|
+
property is arbitrary code, and evaluating one to go looking for a transport
|
|
266
|
+
could open a connection, mint a token or raise from somewhere unrelated. The
|
|
267
|
+
instance dictionary is inert.
|
|
268
|
+
|
|
269
|
+
Covers the shapes that exist today — ``openai``/``anthropic`` (``_client``,
|
|
270
|
+
on ``httpx`` up to openai 2 and on ``httpx2`` from openai 3), ``google-genai``
|
|
271
|
+
(``_api_client._httpx_client`` *and* ``_async_httpx_client``), ``mistralai``
|
|
272
|
+
(``sdk_configuration.client``) — without a descriptor field naming any of
|
|
273
|
+
them, because the point of this net is to catch what nobody declared.
|
|
274
|
+
|
|
275
|
+
Every match, not the first: a client that owns a blocking and an async
|
|
276
|
+
transport at once has two routes out, and guarding one of them guards
|
|
277
|
+
nothing. A matched transport is not walked into — its own internals hold
|
|
278
|
+
pools and connections, never another client's route to the provider.
|
|
279
|
+
|
|
280
|
+
Returns an empty list *and* ``complete=True`` when there is nothing to
|
|
281
|
+
guard, which is the honest answer for a client that owns no HTTP transport:
|
|
282
|
+
it cannot leak a request through one. Running out of budget is a different
|
|
283
|
+
answer and is reported as one — ``complete=False`` — because what was not
|
|
284
|
+
reached may well have been a second route out.
|
|
285
|
+
"""
|
|
286
|
+
found: list[_Transport] = []
|
|
287
|
+
queue: deque[tuple[Any, int]] = deque([(client, 0)])
|
|
288
|
+
seen: set[int] = {id(client)}
|
|
289
|
+
budget = _MAX_NODES
|
|
290
|
+
while queue:
|
|
291
|
+
node, depth = queue.popleft()
|
|
292
|
+
if _is_transport(node):
|
|
293
|
+
found.append(node)
|
|
294
|
+
continue
|
|
295
|
+
if depth >= _MAX_DEPTH:
|
|
296
|
+
continue
|
|
297
|
+
try:
|
|
298
|
+
children = list(vars(node).values())
|
|
299
|
+
except TypeError:
|
|
300
|
+
continue # no instance dictionary (slots, builtin): nothing to walk
|
|
301
|
+
for child in children:
|
|
302
|
+
# Checked BEFORE the child is spent, not after: giving up on the
|
|
303
|
+
# object that exhausted the budget would report ``partial`` for a
|
|
304
|
+
# graph the walk had in fact just finished crossing. Incomplete
|
|
305
|
+
# means "there was one more and we did not look at it".
|
|
306
|
+
if budget <= 0:
|
|
307
|
+
return _Scan(found, complete=False)
|
|
308
|
+
budget -= 1
|
|
309
|
+
if id(child) not in seen:
|
|
310
|
+
seen.add(id(child))
|
|
311
|
+
queue.append((child, depth + 1))
|
|
312
|
+
return _Scan(found, complete=True)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _takes_no_redirects(transport: _Transport) -> bool:
|
|
316
|
+
"""Whether this transport's ``send`` will accept ``follow_redirects=False``.
|
|
317
|
+
|
|
318
|
+
Asked of the signature, which is what the call will be judged against.
|
|
319
|
+
``**kwargs`` counts as acceptance, deliberately: it is how every recorder,
|
|
320
|
+
retry shim and piece of application instrumentation wraps a ``send``, and
|
|
321
|
+
reading it as a refusal would refuse the clients this SDK is most often
|
|
322
|
+
handed. ``httpx`` and ``httpx2`` both name the keyword outright.
|
|
323
|
+
|
|
324
|
+
A signature that cannot be read at all — a ``send`` implemented in C, a
|
|
325
|
+
callable object that will not describe itself — is a refusal. Neither
|
|
326
|
+
remaining outcome is acceptable, so there is nothing to fall back on.
|
|
327
|
+
"""
|
|
328
|
+
try:
|
|
329
|
+
parameters = inspect.signature(transport.send).parameters.values()
|
|
330
|
+
except (TypeError, ValueError):
|
|
331
|
+
return False
|
|
332
|
+
return any(
|
|
333
|
+
(
|
|
334
|
+
parameter.name == _NO_REDIRECTS
|
|
335
|
+
and parameter.kind
|
|
336
|
+
in (
|
|
337
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
338
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
339
|
+
)
|
|
340
|
+
)
|
|
341
|
+
or parameter.kind is inspect.Parameter.VAR_KEYWORD
|
|
342
|
+
for parameter in parameters
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def unredirectable_route(client: Any) -> str | None:
|
|
347
|
+
"""Describe a transport that cannot be told to refuse redirects, if any.
|
|
348
|
+
|
|
349
|
+
A preflight, in the shape of ``_destination.unguarded_route``: it changes
|
|
350
|
+
nothing and is asked before ``wrap()`` patches a single method, so a client
|
|
351
|
+
it names is refused whole rather than left half-enforced. It walks the
|
|
352
|
+
object graph a second time, which costs a bounded walk once per wrap and
|
|
353
|
+
nothing at all on the request path.
|
|
354
|
+
|
|
355
|
+
``None`` means every transport found will take the keyword — including the
|
|
356
|
+
case where there is no transport to find, which the caller already judges on
|
|
357
|
+
its own terms (a stand-in with no HTTP client of its own cannot leak through
|
|
358
|
+
one).
|
|
359
|
+
"""
|
|
360
|
+
for transport in _find_transports(client).transports:
|
|
361
|
+
if not _takes_no_redirects(transport):
|
|
362
|
+
return (
|
|
363
|
+
f"its `{type(transport).__name__}.send` does not accept "
|
|
364
|
+
f"`{_NO_REDIRECTS}`, which every authorized request is sent with"
|
|
365
|
+
)
|
|
366
|
+
return None
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def install(
|
|
370
|
+
client: Any,
|
|
371
|
+
descriptor: ProviderDescriptor,
|
|
372
|
+
token_has_provenance: Provenance | None = None,
|
|
373
|
+
*,
|
|
374
|
+
undo: list[Callable[[], None]] | None = None,
|
|
375
|
+
) -> NetState:
|
|
376
|
+
"""Guard every HTTP transport ``client`` owns, and say what that came to.
|
|
377
|
+
|
|
378
|
+
Patches ``send`` on each transport *instance*, so nothing global and no other
|
|
379
|
+
client is affected — including the SDK's own connection to the ID Side
|
|
380
|
+
cloud, which is a different transport that is not reachable from here.
|
|
381
|
+
Idempotent via a marker, and a derived client that shares its parent's
|
|
382
|
+
transport (``OpenAI.copy()`` does) is already covered.
|
|
383
|
+
|
|
384
|
+
Three answers, because three things are true and only one of them used to be
|
|
385
|
+
said. This value goes straight into the journal as ``net=``, and that field
|
|
386
|
+
is read as "is this client covered?":
|
|
387
|
+
|
|
388
|
+
- ``True`` — the search finished, and every transport it found is guarded.
|
|
389
|
+
- ``False`` — the search finished and found nothing to guard. A client that
|
|
390
|
+
owns no HTTP transport cannot leak a request through one; that is the shape
|
|
391
|
+
of every fake in the test suite, and it is not an error.
|
|
392
|
+
- ``"partial"`` — at least one route out of this client is, or may be,
|
|
393
|
+
unguarded: the walk ran out of budget before it finished, or a transport
|
|
394
|
+
refused the patch. Changes from this attempt are rolled back;
|
|
395
|
+
``consent.wrap()`` treats the result as unsupported and refuses the client.
|
|
396
|
+
|
|
397
|
+
``undo``, when given, receives what reverses an install that succeeded. One
|
|
398
|
+
wrap guards several clients — the wrapped one, and every client named in
|
|
399
|
+
``credentials`` — and an install that went through is not rolled back by a
|
|
400
|
+
later one that did not: the caller has to be able to do it.
|
|
401
|
+
"""
|
|
402
|
+
scan = _find_transports(client)
|
|
403
|
+
# Discovery is a preflight. If the bounded walk did not finish, mutating the
|
|
404
|
+
# routes it happened to see would leave a failed wrap in a half-guarded state.
|
|
405
|
+
if not scan.complete:
|
|
406
|
+
return "partial"
|
|
407
|
+
# A list, deliberately, and not a generator inside ``all()``: every
|
|
408
|
+
# transport has to be offered the patch, and short-circuiting on the first
|
|
409
|
+
# refusal would skip checking the ones after it. Every mutation is recorded
|
|
410
|
+
# so a partial result restores only what this install attempt changed.
|
|
411
|
+
provenance = token_has_provenance or (lambda _token, _method, _path, _origin: False)
|
|
412
|
+
guarded: list[bool] = []
|
|
413
|
+
rollbacks: list[Callable[[], None]] = []
|
|
414
|
+
for transport in scan.transports:
|
|
415
|
+
existing = getattr(transport.send, _GUARD_MARKER, None)
|
|
416
|
+
instance_state = getattr(transport, "__dict__", None)
|
|
417
|
+
had_own_send = isinstance(instance_state, dict) and "send" in instance_state
|
|
418
|
+
previous_send = (
|
|
419
|
+
instance_state.get("send") if isinstance(instance_state, dict) else None
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
guarded.append(_guard_one(transport, descriptor.token_header, provenance))
|
|
423
|
+
if isinstance(existing, list):
|
|
424
|
+
|
|
425
|
+
def _remove_provenance(
|
|
426
|
+
provenances: list[Provenance] = existing,
|
|
427
|
+
predicate: Provenance = provenance,
|
|
428
|
+
) -> None:
|
|
429
|
+
with _PROVENANCE_LOCK:
|
|
430
|
+
for index in range(len(provenances) - 1, -1, -1):
|
|
431
|
+
if provenances[index] is predicate:
|
|
432
|
+
provenances.pop(index)
|
|
433
|
+
return
|
|
434
|
+
|
|
435
|
+
rollbacks.append(_remove_provenance)
|
|
436
|
+
else:
|
|
437
|
+
|
|
438
|
+
def _restore_send(
|
|
439
|
+
target: _Transport = transport,
|
|
440
|
+
had_own: bool = had_own_send,
|
|
441
|
+
previous: Any = previous_send,
|
|
442
|
+
) -> None:
|
|
443
|
+
try:
|
|
444
|
+
if had_own:
|
|
445
|
+
setattr(target, "send", previous) # noqa: B010
|
|
446
|
+
elif "send" in getattr(target, "__dict__", {}):
|
|
447
|
+
delattr(target, "send")
|
|
448
|
+
except Exception:
|
|
449
|
+
# A hostile setter can make perfect rollback impossible. The
|
|
450
|
+
# install still reports partial and never claims coverage.
|
|
451
|
+
return
|
|
452
|
+
|
|
453
|
+
rollbacks.append(_restore_send)
|
|
454
|
+
|
|
455
|
+
if not all(guarded):
|
|
456
|
+
for rollback in reversed(rollbacks):
|
|
457
|
+
rollback()
|
|
458
|
+
return "partial"
|
|
459
|
+
if undo is not None:
|
|
460
|
+
undo.extend(rollbacks)
|
|
461
|
+
return bool(guarded)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _guard_one(
|
|
465
|
+
transport: _Transport,
|
|
466
|
+
token_header: str,
|
|
467
|
+
token_has_provenance: Provenance,
|
|
468
|
+
) -> bool:
|
|
469
|
+
"""Wrap one transport's ``send``, in the shape that transport is written in.
|
|
470
|
+
|
|
471
|
+
The blocking and the async halves get different wrappers because they must:
|
|
472
|
+
an ``async def`` on a blocking client returns a coroutine nobody awaits, and
|
|
473
|
+
a plain ``def`` on an async one hands back a response where a coroutine was
|
|
474
|
+
expected. Only the wrapping differs — the question asked is the same one, and
|
|
475
|
+
both ask it before anything is sent.
|
|
476
|
+
|
|
477
|
+
Returns whether this transport ended up guarded. A transport that will not
|
|
478
|
+
take the patch — sealed attributes, a read-only proxy — is reported to
|
|
479
|
+
``consent.wrap()``. The caller installs the net before patching call sites,
|
|
480
|
+
so refusal leaves them untouched.
|
|
481
|
+
"""
|
|
482
|
+
existing = getattr(transport.send, _GUARD_MARKER, None)
|
|
483
|
+
if isinstance(existing, list):
|
|
484
|
+
_compact(existing)
|
|
485
|
+
with _PROVENANCE_LOCK:
|
|
486
|
+
existing.append(token_has_provenance)
|
|
487
|
+
return True
|
|
488
|
+
|
|
489
|
+
provenances = [token_has_provenance]
|
|
490
|
+
|
|
491
|
+
guarded: Any
|
|
492
|
+
if _sends_coroutines(transport):
|
|
493
|
+
async_original = transport.send
|
|
494
|
+
|
|
495
|
+
@functools.wraps(async_original)
|
|
496
|
+
async def _guarded_async(request: Any, *args: Any, **kwargs: Any) -> Any:
|
|
497
|
+
if _refuse_unenforced(request, token_header, provenances):
|
|
498
|
+
kwargs["follow_redirects"] = False
|
|
499
|
+
return await async_original(request, *args, **kwargs)
|
|
500
|
+
|
|
501
|
+
guarded = _guarded_async
|
|
502
|
+
else:
|
|
503
|
+
sync_original = transport.send
|
|
504
|
+
|
|
505
|
+
@functools.wraps(sync_original)
|
|
506
|
+
def _guarded_sync(request: Any, *args: Any, **kwargs: Any) -> Any:
|
|
507
|
+
if _refuse_unenforced(request, token_header, provenances):
|
|
508
|
+
kwargs["follow_redirects"] = False
|
|
509
|
+
return sync_original(request, *args, **kwargs)
|
|
510
|
+
|
|
511
|
+
guarded = _guarded_sync
|
|
512
|
+
|
|
513
|
+
setattr(guarded, _GUARD_MARKER, provenances)
|
|
514
|
+
try:
|
|
515
|
+
# setattr, not `transport.send = …`: this deliberately shadows a method
|
|
516
|
+
# on ONE instance, which is exactly what a type checker is right to flag
|
|
517
|
+
# when it is not deliberate.
|
|
518
|
+
setattr(transport, "send", guarded) # noqa: B010
|
|
519
|
+
except Exception:
|
|
520
|
+
return False
|
|
521
|
+
# Read back rather than trusted: a ``__setattr__`` that swallows the write
|
|
522
|
+
# raises nothing, and the difference between "patched" and "silently
|
|
523
|
+
# ignored" is the whole difference between ``net=true`` and ``net=partial``.
|
|
524
|
+
return isinstance(getattr(transport.send, _GUARD_MARKER, None), list)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _refuse_unenforced(
|
|
528
|
+
request: Any,
|
|
529
|
+
token_header: str,
|
|
530
|
+
token_provenances: list[Provenance],
|
|
531
|
+
) -> bool:
|
|
532
|
+
"""The whole decision, shared by both wrappers so it cannot differ.
|
|
533
|
+
|
|
534
|
+
The method, path and origin bind the private permit; the named header
|
|
535
|
+
supplies the token it must match. If the blocking and async net ever
|
|
536
|
+
answered differently, the async client would be the one that leaked.
|
|
537
|
+
|
|
538
|
+
Returns whether the request went through as an AUTHORIZED one — the only
|
|
539
|
+
kind that must not follow a redirect. A bodyless request passes without
|
|
540
|
+
being read any further, exactly as before.
|
|
541
|
+
"""
|
|
542
|
+
method = request.method.upper()
|
|
543
|
+
if method in _BODYLESS_METHODS:
|
|
544
|
+
return False
|
|
545
|
+
live = _live(token_provenances)
|
|
546
|
+
token = request.headers.get(token_header)
|
|
547
|
+
path = request.url.path
|
|
548
|
+
origin = origin_of(request.url)
|
|
549
|
+
if not isinstance(token, str) or not any(
|
|
550
|
+
check(token, method, path, origin) for check in live
|
|
551
|
+
):
|
|
552
|
+
raise UnenforcedCallError(_refusal(request.method, path))
|
|
553
|
+
return True
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _refusal(method: str, path: str) -> str:
|
|
557
|
+
"""Say what was stopped, using only what the guard is allowed to look at.
|
|
558
|
+
|
|
559
|
+
The method and the URL *path* — never the query string, which is part of the
|
|
560
|
+
request and can carry identifiers.
|
|
561
|
+
"""
|
|
562
|
+
return (
|
|
563
|
+
f"{method} {path} was about to be sent by a consent-enforced client "
|
|
564
|
+
"without going through ID Side consent enforcement, or to an origin other "
|
|
565
|
+
"than the one the client was wrapped with, so it was NOT sent. "
|
|
566
|
+
"Only the endpoints the provider descriptor declares are enforced; this "
|
|
567
|
+
"one is not, and letting it through would mean an AI call reaching the "
|
|
568
|
+
"provider with no proof of consent and no ledger entry. Non-inference "
|
|
569
|
+
"traffic (uploads, batches, management endpoints) belongs on a second, "
|
|
570
|
+
"UNWRAPPED client — there is no bypass option on a wrapped one. If this "
|
|
571
|
+
"endpoint does carry inference, it needs a call site in the descriptor: "
|
|
572
|
+
"please report it at https://github.com/idside-eu/idside-sdk/issues."
|
|
573
|
+
)
|