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/_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
+ )