@passioncode-ai/fabric-agent-adapter 0.10.0 → 0.11.1
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.
- package/CHANGELOG.md +50 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +5 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +84 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +427 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +761 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +43 -10
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py
ADDED
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The callee's side of fabric-delegation/0.1 (Fabric Agent Contract DEC-0039, Fabric ADR-0144): check a
|
|
3
|
+
delegation token or an approval offline against the estate's published keys, and answer `ok` or one refusal
|
|
4
|
+
code. A port of the contract's `src/delegation.ts` at the pinned revision; the shared vectors in
|
|
5
|
+
`test/fixtures/delegation/vectors.json` hold this module to that verifier's exact answers.
|
|
6
|
+
|
|
7
|
+
Standard library only. Ed25519 (RFC 8032) is verified here in plain Python: verification uses only public data,
|
|
8
|
+
so it needs no constant-time arithmetic; it is cofactorless and strict as OpenSSL is (S below L, A decodes,
|
|
9
|
+
R compared by its encoding).
|
|
10
|
+
|
|
11
|
+
from fabric_delegation import JwksCache, delegation_gate
|
|
12
|
+
gate = delegation_gate(audience="example-agent", jwks=JwksCache(well_known["auth"]["delegation"]["jwks"]),
|
|
13
|
+
effects={"example.write": "draft"}, issuer=well_known["auth"]["delegation"]["issuer"])
|
|
14
|
+
server = fabric_interop.McpToolServer("example-agent", VERSION, authorize=gate)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import base64
|
|
20
|
+
import hashlib
|
|
21
|
+
import ipaddress
|
|
22
|
+
import json
|
|
23
|
+
import math
|
|
24
|
+
import os
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
import re
|
|
27
|
+
import secrets
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple
|
|
31
|
+
import urllib.error
|
|
32
|
+
import urllib.parse
|
|
33
|
+
import urllib.request
|
|
34
|
+
|
|
35
|
+
REFUSAL_CODES = (
|
|
36
|
+
"unauthenticated", "token-expired", "wrong-audience", "scope-not-granted", "effect-above-ceiling", "no-grant",
|
|
37
|
+
"grant-expired", "grant-revoked", "limit-reached", "approval-required", "approval-invalid", "approval-reused",
|
|
38
|
+
"principal-unproven", "credential-source-refused", "chain-too-deep", "issuer-unavailable", "unknown-state",
|
|
39
|
+
)
|
|
40
|
+
EFFECT_CLASSES = ("none", "draft", "publish", "send", "reply", "charge", "delete", "merge", "deploy", "change-policy")
|
|
41
|
+
DELEGATION_HEADER = "Fabric-Delegation"
|
|
42
|
+
DELEGATION_TYP = "fabric-delegation+jwt"
|
|
43
|
+
APPROVAL_TYP = "fabric-approval+jwt"
|
|
44
|
+
MAX_DELEGATION_LIFETIME_SECONDS = 600
|
|
45
|
+
MAX_APPROVAL_LIFETIME_SECONDS = 86_400
|
|
46
|
+
MAX_CHAIN_DEPTH = 4
|
|
47
|
+
LEEWAY_SECONDS = 60
|
|
48
|
+
# DL-15: a callee caches the key set at most 10 minutes and re-reads it at most once a minute for an unknown kid.
|
|
49
|
+
JWKS_MAX_AGE_SECONDS = 600
|
|
50
|
+
JWKS_REREAD_SECONDS = 60
|
|
51
|
+
# After a failed read, wait this long before trying again, so a down issuer does not cost every call a fetch.
|
|
52
|
+
JWKS_RETRY_SECONDS = 10
|
|
53
|
+
# JavaScript's String.prototype.trim set, so a header is trimmed as the contract's verifier trims it.
|
|
54
|
+
_JS_WHITESPACE = "\t\n\v\f\r \u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff"
|
|
55
|
+
|
|
56
|
+
# DL-19: the HTTP status of each refusal, and the sentence that goes in error.message.
|
|
57
|
+
REFUSAL_STATUS = {"unauthenticated": 401, "token-expired": 401, "principal-unproven": 401,
|
|
58
|
+
"issuer-unavailable": 503, "unknown-state": 503}
|
|
59
|
+
REFUSAL_MESSAGES = {
|
|
60
|
+
"unauthenticated": "No delegation token, or one not signed by a published key or not of this profile.",
|
|
61
|
+
"token-expired": "The delegation token has expired; mint a new one.",
|
|
62
|
+
"wrong-audience": "The delegation token is for another callee.",
|
|
63
|
+
"scope-not-granted": "The called capability is not in the token's scope.",
|
|
64
|
+
"effect-above-ceiling": "The capability's effect is above the token's ceiling.",
|
|
65
|
+
"no-grant": "No live grant covers this call.",
|
|
66
|
+
"grant-expired": "The grant has expired.",
|
|
67
|
+
"grant-revoked": "The grant was revoked.",
|
|
68
|
+
"limit-reached": "A spend window is exhausted.",
|
|
69
|
+
"approval-required": "This action needs an approval; ask the hub for one with the action digest.",
|
|
70
|
+
"approval-invalid": "The approval is not valid for this action, callee or time.",
|
|
71
|
+
"approval-reused": "The approval was already used.",
|
|
72
|
+
"principal-unproven": "The caller's proof is missing or stale.",
|
|
73
|
+
"credential-source-refused": "The grant names a credential source that is not allowed.",
|
|
74
|
+
"chain-too-deep": "The actor chain is deeper than four.",
|
|
75
|
+
"issuer-unavailable": "The estate's published keys cannot be read.",
|
|
76
|
+
"unknown-state": "The state cannot be established, so the call is refused.",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
_B64URL = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
80
|
+
_MISSING = object()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _refuse(code: str, detail: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
84
|
+
out: Dict[str, Any] = {"ok": False, "code": code, "detail": detail}
|
|
85
|
+
if data:
|
|
86
|
+
out["data"] = data
|
|
87
|
+
return out
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _is_string(value: Any) -> bool:
|
|
91
|
+
return isinstance(value, str) and len(value) > 0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _is_time(value: Any) -> bool:
|
|
95
|
+
if isinstance(value, bool):
|
|
96
|
+
return False
|
|
97
|
+
if isinstance(value, int):
|
|
98
|
+
return value >= 0
|
|
99
|
+
return isinstance(value, float) and math.isfinite(value) and value.is_integer() and value >= 0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# --- Ed25519 (RFC 8032 §5.1) ----------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
_P = 2 ** 255 - 19
|
|
105
|
+
_L = 2 ** 252 + 27742317777372353535851937790883648493
|
|
106
|
+
_D = (-121665 * pow(121666, _P - 2, _P)) % _P
|
|
107
|
+
_SQRT_M1 = pow(2, (_P - 1) // 4, _P)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _add(a: Tuple[int, int, int, int], b: Tuple[int, int, int, int]) -> Tuple[int, int, int, int]:
|
|
111
|
+
x1, y1, z1, t1 = a
|
|
112
|
+
x2, y2, z2, t2 = b
|
|
113
|
+
aa = (y1 - x1) * (y2 - x2) % _P
|
|
114
|
+
bb = (y1 + x1) * (y2 + x2) % _P
|
|
115
|
+
cc = 2 * t1 * t2 * _D % _P
|
|
116
|
+
dd = 2 * z1 * z2 % _P
|
|
117
|
+
e, f, g, h = bb - aa, dd - cc, dd + cc, bb + aa
|
|
118
|
+
return (e * f % _P, g * h % _P, f * g % _P, e * h % _P)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _mul(scalar: int, point: Tuple[int, int, int, int]) -> Tuple[int, int, int, int]:
|
|
122
|
+
result = (0, 1, 1, 0)
|
|
123
|
+
while scalar:
|
|
124
|
+
if scalar & 1:
|
|
125
|
+
result = _add(result, point)
|
|
126
|
+
point = _add(point, point)
|
|
127
|
+
scalar >>= 1
|
|
128
|
+
return result
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _decode_point(data: bytes) -> Optional[Tuple[int, int, int, int]]:
|
|
132
|
+
if len(data) != 32:
|
|
133
|
+
return None
|
|
134
|
+
y = int.from_bytes(data, "little")
|
|
135
|
+
sign = y >> 255
|
|
136
|
+
y &= (1 << 255) - 1
|
|
137
|
+
if y >= _P:
|
|
138
|
+
return None
|
|
139
|
+
x2 = (y * y - 1) * pow(_D * y * y + 1, _P - 2, _P) % _P
|
|
140
|
+
if x2 == 0:
|
|
141
|
+
if sign:
|
|
142
|
+
return None
|
|
143
|
+
return (0, y, 1, 0)
|
|
144
|
+
x = pow(x2, (_P + 3) // 8, _P)
|
|
145
|
+
if (x * x - x2) % _P:
|
|
146
|
+
x = x * _SQRT_M1 % _P
|
|
147
|
+
if (x * x - x2) % _P:
|
|
148
|
+
return None
|
|
149
|
+
if x & 1 != sign:
|
|
150
|
+
x = _P - x
|
|
151
|
+
return (x, y, 1, x * y % _P)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _encode_point(point: Tuple[int, int, int, int]) -> bytes:
|
|
155
|
+
x, y, z, _ = point
|
|
156
|
+
zi = pow(z, _P - 2, _P)
|
|
157
|
+
x, y = x * zi % _P, y * zi % _P
|
|
158
|
+
return (y | ((x & 1) << 255)).to_bytes(32, "little")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
_G = _decode_point(bytes.fromhex("5866666666666666666666666666666666666666666666666666666666666666"))
|
|
162
|
+
_IDENTITY = (1).to_bytes(32, "little")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def ed25519_verify(public_key: bytes, message: bytes, signature: bytes) -> bool:
|
|
166
|
+
"""RFC 8032 §5.1.7, cofactorless: [S]B == R + [k]A, compared by R's encoding. False, never an exception."""
|
|
167
|
+
if len(public_key) != 32 or len(signature) != 64:
|
|
168
|
+
return False
|
|
169
|
+
s = int.from_bytes(signature[32:], "little")
|
|
170
|
+
if s >= _L:
|
|
171
|
+
return False
|
|
172
|
+
a = _decode_point(public_key)
|
|
173
|
+
if a is None or _encode_point(_mul(8, a)) == _IDENTITY:
|
|
174
|
+
return False # a small-order key would accept R = [S]B for a quarter or more of all messages; OpenSSL refuses it
|
|
175
|
+
k = int.from_bytes(hashlib.sha512(signature[:32] + public_key + message).digest(), "little") % _L
|
|
176
|
+
neg_a = ((-a[0]) % _P, a[1], a[2], (-a[3]) % _P)
|
|
177
|
+
return secrets.compare_digest(_encode_point(_add(_mul(s, _G), _mul(k, neg_a))), signature[:32])
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# --- the compact document ------------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
def _b64decode(segment: str) -> bytes:
|
|
183
|
+
"""Base64url without padding, as Node reads it: a dangling sixth bit group is ignored."""
|
|
184
|
+
if len(segment) % 4 == 1:
|
|
185
|
+
segment = segment[:-1]
|
|
186
|
+
return base64.urlsafe_b64decode(segment + "=" * (-len(segment) % 4))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class _Duplicate(ValueError):
|
|
190
|
+
pass
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _no_duplicates(pairs: List[Tuple[str, Any]]) -> Dict[str, Any]:
|
|
194
|
+
seen: Dict[str, Any] = {}
|
|
195
|
+
for key, value in pairs:
|
|
196
|
+
if key in seen:
|
|
197
|
+
raise _Duplicate(key)
|
|
198
|
+
seen[key] = value
|
|
199
|
+
return seen
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _no_constant(name: str) -> Any:
|
|
203
|
+
raise ValueError(name) # NaN and Infinity are not JSON
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _decode_segment(segment: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
|
207
|
+
if not _B64URL.match(segment):
|
|
208
|
+
return None, "malformed"
|
|
209
|
+
try:
|
|
210
|
+
text = _b64decode(segment).decode("utf-8", "replace")
|
|
211
|
+
value = json.loads(text, object_pairs_hook=_no_duplicates, parse_constant=_no_constant)
|
|
212
|
+
except _Duplicate:
|
|
213
|
+
try:
|
|
214
|
+
value = json.loads(text, parse_constant=_no_constant)
|
|
215
|
+
except (ValueError, RecursionError):
|
|
216
|
+
return None, "malformed"
|
|
217
|
+
return (None, "duplicate-member") if isinstance(value, dict) else (None, "malformed")
|
|
218
|
+
except (ValueError, TypeError, RecursionError):
|
|
219
|
+
return None, "malformed"
|
|
220
|
+
if not isinstance(value, dict):
|
|
221
|
+
return None, "malformed"
|
|
222
|
+
return value, None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _utf16_length(text: str) -> int:
|
|
226
|
+
return len(text.encode("utf-16-le", "surrogatepass")) // 2
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _parse_compact(token: Any) -> Dict[str, Any]:
|
|
230
|
+
if not isinstance(token, str) or _utf16_length(token) > 8192:
|
|
231
|
+
return {"problem": "malformed"}
|
|
232
|
+
parts = token.split(".")
|
|
233
|
+
if len(parts) != 3:
|
|
234
|
+
return {"problem": "malformed"}
|
|
235
|
+
h, p, s = parts
|
|
236
|
+
header, header_problem = _decode_segment(h)
|
|
237
|
+
payload, payload_problem = _decode_segment(p)
|
|
238
|
+
if not _B64URL.match(s):
|
|
239
|
+
return {"problem": "malformed"}
|
|
240
|
+
for problem in (header_problem, payload_problem):
|
|
241
|
+
if problem:
|
|
242
|
+
return {"problem": problem}
|
|
243
|
+
try:
|
|
244
|
+
signature = _b64decode(s)
|
|
245
|
+
except ValueError:
|
|
246
|
+
signature = b""
|
|
247
|
+
return {"header": header, "payload": payload, "signing_input": ("%s.%s" % (h, p)).encode("ascii"), "signature": signature}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _header_problem(header: Dict[str, Any], typ: str) -> Optional[str]:
|
|
251
|
+
if header.get("alg") != "EdDSA":
|
|
252
|
+
return "alg-not-eddsa"
|
|
253
|
+
if header.get("typ") != typ:
|
|
254
|
+
return "wrong-typ"
|
|
255
|
+
if not _is_string(header.get("kid")):
|
|
256
|
+
return "no-kid"
|
|
257
|
+
for member in ("crit", "jku", "jwk", "x5u", "x5c"):
|
|
258
|
+
if member in header:
|
|
259
|
+
return "header-" + member
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _key_for(jwks: Dict[str, Any], kid: str) -> Dict[str, Any]:
|
|
264
|
+
matches = [k for k in jwks["keys"] if isinstance(k, dict) and k.get("kid") == kid]
|
|
265
|
+
if len(matches) > 1:
|
|
266
|
+
return {"problem": "duplicate-kid"}
|
|
267
|
+
if not matches:
|
|
268
|
+
return {"problem": "unknown-key"}
|
|
269
|
+
jwk = matches[0]
|
|
270
|
+
# A key published for another use or algorithm is never a candidate (RFC 7517 §4.2, §4.4).
|
|
271
|
+
if ("use" in jwk and jwk["use"] != "sig") or ("alg" in jwk and jwk["alg"] != "EdDSA"):
|
|
272
|
+
return {"problem": "key-not-for-signing"}
|
|
273
|
+
if jwk.get("kty") != "OKP" or jwk.get("crv") != "Ed25519" or not _is_string(jwk.get("x")) or "d" in jwk:
|
|
274
|
+
return {"problem": "unusable-key"}
|
|
275
|
+
if not _B64URL.match(jwk["x"]):
|
|
276
|
+
return {"problem": "unusable-key"}
|
|
277
|
+
try:
|
|
278
|
+
raw = _b64decode(jwk["x"])
|
|
279
|
+
except ValueError:
|
|
280
|
+
return {"problem": "unusable-key"}
|
|
281
|
+
if len(raw) != 32:
|
|
282
|
+
return {"problem": "unusable-key"}
|
|
283
|
+
return {"key": raw}
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _has_keys(jwks: Any) -> bool:
|
|
287
|
+
return isinstance(jwks, dict) and isinstance(jwks.get("keys"), list) and len(jwks["keys"]) > 0
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _authentic(token: Any, jwks: Dict[str, Any], typ: str) -> Dict[str, Any]:
|
|
291
|
+
"""Parse, check the header, find the key, verify the signature. A fault of the published keys is the issuer's."""
|
|
292
|
+
compact = _parse_compact(token)
|
|
293
|
+
if "problem" in compact:
|
|
294
|
+
return compact
|
|
295
|
+
problem = _header_problem(compact["header"], typ)
|
|
296
|
+
if problem:
|
|
297
|
+
return {"problem": problem}
|
|
298
|
+
kid = compact["header"]["kid"]
|
|
299
|
+
found = _key_for(jwks, kid)
|
|
300
|
+
if "problem" in found:
|
|
301
|
+
return {"problem": found["problem"], "issuer": found["problem"] in ("unusable-key", "duplicate-kid")}
|
|
302
|
+
if len(compact["signature"]) != 64 or not ed25519_verify(found["key"], compact["signing_input"], compact["signature"]):
|
|
303
|
+
return {"problem": "bad-signature"}
|
|
304
|
+
return {"compact": compact, "kid": kid}
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def chain_depth(act: Any) -> float:
|
|
308
|
+
"""Depth of an actor chain; infinity for a chain that is not one (a missing or non-string sub at any level)."""
|
|
309
|
+
depth = 0
|
|
310
|
+
node = act
|
|
311
|
+
while node is not _MISSING:
|
|
312
|
+
if not isinstance(node, dict) or not _is_string(node.get("sub")):
|
|
313
|
+
return math.inf
|
|
314
|
+
depth += 1
|
|
315
|
+
if depth > 64:
|
|
316
|
+
return depth
|
|
317
|
+
node = node.get("act", _MISSING)
|
|
318
|
+
return depth
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _chain_of(act: Dict[str, Any]) -> List[str]:
|
|
322
|
+
chain = []
|
|
323
|
+
node: Any = act
|
|
324
|
+
while isinstance(node, dict):
|
|
325
|
+
chain.append(node["sub"])
|
|
326
|
+
node = node.get("act")
|
|
327
|
+
return chain
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def effect_closure(ceiling: List[str]) -> set:
|
|
331
|
+
"""`none` is below every class and `draft` below every external one; external classes are not ordered."""
|
|
332
|
+
admitted = set(ceiling)
|
|
333
|
+
if ceiling:
|
|
334
|
+
admitted.add("none")
|
|
335
|
+
if any(effect != "none" for effect in ceiling):
|
|
336
|
+
admitted.add("draft")
|
|
337
|
+
return admitted
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def effect_within(effect: str, ceiling: List[str]) -> bool:
|
|
341
|
+
return effect in effect_closure(ceiling)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _delegation_shape(payload: Dict[str, Any]) -> bool:
|
|
345
|
+
if not all(_is_string(payload.get(k)) for k in ("iss", "sub", "aud", "scope", "jti")):
|
|
346
|
+
return False
|
|
347
|
+
if not _is_time(payload.get("iat")) or not _is_time(payload.get("exp")):
|
|
348
|
+
return False
|
|
349
|
+
effect, grant = payload.get("effect"), payload.get("grant")
|
|
350
|
+
if not isinstance(effect, list) or not effect or not all(isinstance(e, str) and e in EFFECT_CLASSES for e in effect):
|
|
351
|
+
return False
|
|
352
|
+
if not isinstance(grant, list) or not grant or not all(_is_string(g) for g in grant):
|
|
353
|
+
return False
|
|
354
|
+
return isinstance(payload.get("act"), dict)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _approval_shape(payload: Dict[str, Any]) -> bool:
|
|
358
|
+
if not all(_is_string(payload.get(k)) for k in ("iss", "actionDigest", "aud", "approver", "jti")):
|
|
359
|
+
return False
|
|
360
|
+
if not _is_time(payload.get("iat")) or not _is_time(payload.get("exp")):
|
|
361
|
+
return False
|
|
362
|
+
return payload.get("method") in ("prompt", "operator-channel", "device")
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def verify_delegation(token: Any, *, audience: str, jwks: Any, now: float, capability: str, effect: str,
|
|
366
|
+
issuer: Optional[str] = None, read_only: bool = False) -> Dict[str, Any]:
|
|
367
|
+
"""DL-13, in its fixed order: nothing about a token is believed before its signature is.
|
|
368
|
+
`now` is seconds since the epoch. Returns {ok: True, claims, principal, caller, chain, kid} or a refusal
|
|
369
|
+
{ok: False, code, detail[, data]} whose detail is a short machine word, never the token."""
|
|
370
|
+
if not _has_keys(jwks):
|
|
371
|
+
return _refuse("issuer-unavailable", "no-published-keys")
|
|
372
|
+
checked = _authentic(token, jwks, DELEGATION_TYP)
|
|
373
|
+
if "problem" in checked:
|
|
374
|
+
return _refuse("issuer-unavailable" if checked.get("issuer") else "unauthenticated", checked["problem"])
|
|
375
|
+
claims = checked["compact"]["payload"]
|
|
376
|
+
if not _delegation_shape(claims):
|
|
377
|
+
return _refuse("unauthenticated", "malformed-claims")
|
|
378
|
+
if issuer is not None and claims["iss"] != issuer:
|
|
379
|
+
return _refuse("unauthenticated", "wrong-issuer")
|
|
380
|
+
if claims["exp"] <= claims["iat"] or claims["exp"] - claims["iat"] > MAX_DELEGATION_LIFETIME_SECONDS:
|
|
381
|
+
return _refuse("unauthenticated", "lifetime-above-profile")
|
|
382
|
+
if claims["iat"] > now + LEEWAY_SECONDS:
|
|
383
|
+
return _refuse("unauthenticated", "issued-in-future")
|
|
384
|
+
if "nbf" in claims and (not _is_time(claims["nbf"]) or claims["nbf"] > now + LEEWAY_SECONDS):
|
|
385
|
+
return _refuse("unauthenticated", "not-yet-valid")
|
|
386
|
+
if now > claims["exp"] + LEEWAY_SECONDS:
|
|
387
|
+
return _refuse("token-expired", "expired")
|
|
388
|
+
if claims["aud"] != audience:
|
|
389
|
+
return _refuse("wrong-audience", "audience-mismatch")
|
|
390
|
+
depth = chain_depth(claims["act"])
|
|
391
|
+
if not math.isfinite(depth):
|
|
392
|
+
return _refuse("unauthenticated", "malformed-chain")
|
|
393
|
+
if depth > MAX_CHAIN_DEPTH:
|
|
394
|
+
return _refuse("chain-too-deep", "depth-%d" % depth)
|
|
395
|
+
if capability not in claims["scope"].split(" "):
|
|
396
|
+
return _refuse("scope-not-granted", "capability-not-in-scope")
|
|
397
|
+
if read_only and effect != "none":
|
|
398
|
+
return _refuse("effect-above-ceiling", "read-only-door")
|
|
399
|
+
if not effect_within(effect, claims["effect"]):
|
|
400
|
+
return _refuse("effect-above-ceiling", "effect-%s" % effect)
|
|
401
|
+
return {"ok": True, "claims": claims, "principal": claims["sub"], "caller": claims["act"]["sub"],
|
|
402
|
+
"chain": _chain_of(claims["act"]), "kid": checked["kid"]}
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
class MemoryJtiStore:
|
|
406
|
+
"""Consumed approval ids in memory: for tests and a single process that never restarts mid-approval."""
|
|
407
|
+
|
|
408
|
+
def __init__(self) -> None:
|
|
409
|
+
self._seen: Dict[str, float] = {}
|
|
410
|
+
self._lock = threading.Lock()
|
|
411
|
+
|
|
412
|
+
def consume(self, jti: str, expires_at: float, now: float) -> bool:
|
|
413
|
+
with self._lock:
|
|
414
|
+
for key, until in list(self._seen.items()):
|
|
415
|
+
if until + LEEWAY_SECONDS < now:
|
|
416
|
+
del self._seen[key]
|
|
417
|
+
if jti in self._seen:
|
|
418
|
+
return False
|
|
419
|
+
self._seen[jti] = expires_at
|
|
420
|
+
return True
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
class JtiStoreError(RuntimeError):
|
|
424
|
+
"""A jti store that cannot tell whether an id was consumed; the verifier answers unknown-state."""
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
class FileJtiStore:
|
|
428
|
+
"""Consumed approval ids in a 0600 JSON file, so a second use is refused across a restart (DL-18). One
|
|
429
|
+
service instance writes it (the kit's instance lock keeps other processes out); threads are serialised."""
|
|
430
|
+
|
|
431
|
+
_locks: Dict[str, threading.Lock] = {}
|
|
432
|
+
_locks_guard = threading.Lock()
|
|
433
|
+
|
|
434
|
+
def __init__(self, path: Path) -> None:
|
|
435
|
+
self.path = Path(path)
|
|
436
|
+
with FileJtiStore._locks_guard:
|
|
437
|
+
self._lock = FileJtiStore._locks.setdefault(str(self.path.resolve()), threading.Lock())
|
|
438
|
+
|
|
439
|
+
def consume(self, jti: str, expires_at: float, now: float) -> bool:
|
|
440
|
+
"""True the first time; False for a used id. Raises JtiStoreError when the file cannot be read: forgetting
|
|
441
|
+
every consumed id would let each approval be replayed."""
|
|
442
|
+
with self._lock:
|
|
443
|
+
try:
|
|
444
|
+
seen = json.loads(self.path.read_text())
|
|
445
|
+
except FileNotFoundError:
|
|
446
|
+
seen = {}
|
|
447
|
+
except (OSError, ValueError) as exc:
|
|
448
|
+
raise JtiStoreError("the consumed-approval file cannot be read: %s" % exc.__class__.__name__) from None
|
|
449
|
+
if not isinstance(seen, dict):
|
|
450
|
+
raise JtiStoreError("the consumed-approval file is not a JSON object")
|
|
451
|
+
seen = {k: v for k, v in seen.items() if isinstance(v, (int, float)) and v + LEEWAY_SECONDS >= now}
|
|
452
|
+
if jti in seen:
|
|
453
|
+
return False
|
|
454
|
+
seen[jti] = expires_at
|
|
455
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
456
|
+
temp = self.path.with_name(".%s.%s.tmp" % (self.path.name, secrets.token_hex(4)))
|
|
457
|
+
fd = os.open(str(temp), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
458
|
+
with os.fdopen(fd, "w") as handle:
|
|
459
|
+
json.dump(seen, handle)
|
|
460
|
+
handle.flush()
|
|
461
|
+
os.fsync(handle.fileno())
|
|
462
|
+
os.replace(str(temp), str(self.path))
|
|
463
|
+
return True
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
class _NoReplayProtection:
|
|
467
|
+
def consume(self, jti: str, expires_at: float, now: float) -> bool: # noqa: ARG002
|
|
468
|
+
return True
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
# Every approval may be replayed until it expires. Only on purpose, by name.
|
|
472
|
+
UNSAFE_NO_JTI_STORE = _NoReplayProtection()
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def verify_approval(approval: Optional[str], *, audience: str, jwks: Any, now: float, action_digest: str,
|
|
476
|
+
jti_store: Any, issuer: Optional[str] = None) -> Dict[str, Any]:
|
|
477
|
+
"""DL-18: a missing approval is approval-required carrying the action digest; any fault of the document is
|
|
478
|
+
approval-invalid; a second use is approval-reused. The jti is consumed last."""
|
|
479
|
+
if approval is None or approval == "":
|
|
480
|
+
return _refuse("approval-required", "no-approval", {"actionDigest": action_digest})
|
|
481
|
+
if jti_store is None or not callable(getattr(jti_store, "consume", None)):
|
|
482
|
+
return _refuse("unknown-state", "no-jti-store")
|
|
483
|
+
if not _has_keys(jwks):
|
|
484
|
+
return _refuse("issuer-unavailable", "no-published-keys")
|
|
485
|
+
checked = _authentic(approval, jwks, APPROVAL_TYP)
|
|
486
|
+
if "problem" in checked:
|
|
487
|
+
return _refuse("issuer-unavailable" if checked.get("issuer") else "approval-invalid", checked["problem"])
|
|
488
|
+
claims = checked["compact"]["payload"]
|
|
489
|
+
if not _approval_shape(claims):
|
|
490
|
+
return _refuse("approval-invalid", "malformed-claims")
|
|
491
|
+
if issuer is not None and claims["iss"] != issuer:
|
|
492
|
+
return _refuse("approval-invalid", "wrong-issuer")
|
|
493
|
+
if claims["exp"] <= claims["iat"] or claims["exp"] - claims["iat"] > MAX_APPROVAL_LIFETIME_SECONDS:
|
|
494
|
+
return _refuse("approval-invalid", "lifetime-above-profile")
|
|
495
|
+
if claims["iat"] > now + LEEWAY_SECONDS:
|
|
496
|
+
return _refuse("approval-invalid", "issued-in-future")
|
|
497
|
+
if now > claims["exp"] + LEEWAY_SECONDS:
|
|
498
|
+
return _refuse("approval-invalid", "expired")
|
|
499
|
+
if claims["aud"] != audience:
|
|
500
|
+
return _refuse("approval-invalid", "audience-mismatch")
|
|
501
|
+
if claims["actionDigest"] != action_digest:
|
|
502
|
+
return _refuse("approval-invalid", "digest-mismatch", {"actionDigest": action_digest})
|
|
503
|
+
try:
|
|
504
|
+
fresh = jti_store.consume(claims["jti"], claims["exp"], now)
|
|
505
|
+
except Exception: # a store that cannot answer is an unknown state, never a pass (DL-19)
|
|
506
|
+
return _refuse("unknown-state", "jti-store-failed")
|
|
507
|
+
if not fresh:
|
|
508
|
+
return _refuse("approval-reused", "jti-consumed")
|
|
509
|
+
return {"ok": True, "claims": claims, "approver": claims["approver"], "method": claims["method"], "kid": checked["kid"]}
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _header(headers: Any, name: str) -> Optional[str]:
|
|
513
|
+
if headers is None:
|
|
514
|
+
return None
|
|
515
|
+
if hasattr(headers, "get_all"): # http.server's and email's message: case-insensitive
|
|
516
|
+
values = headers.get_all(name)
|
|
517
|
+
return values[0] if values else None
|
|
518
|
+
if isinstance(headers, Mapping):
|
|
519
|
+
for key, value in headers.items():
|
|
520
|
+
if isinstance(key, str) and key.lower() == name.lower():
|
|
521
|
+
if isinstance(value, (list, tuple)):
|
|
522
|
+
return str(value[0]) if value else ""
|
|
523
|
+
return value
|
|
524
|
+
return None
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def read_delegation_token(headers: Any) -> Optional[Dict[str, str]]:
|
|
528
|
+
"""DL-9, DL-10: the hub's Fabric-Delegation header first (present but empty is still the token, refused
|
|
529
|
+
later, never a fall-through), then a delegation-typ JWT in Authorization: Bearer."""
|
|
530
|
+
forwarded = _header(headers, DELEGATION_HEADER)
|
|
531
|
+
if forwarded is not None:
|
|
532
|
+
return {"token": forwarded.strip(_JS_WHITESPACE), "via": "fabric-delegation"}
|
|
533
|
+
match = re.fullmatch(r"Bearer\s+(\S+)", _header(headers, "Authorization") or "", re.IGNORECASE)
|
|
534
|
+
if match:
|
|
535
|
+
parsed = _parse_compact(match.group(1))
|
|
536
|
+
if "problem" not in parsed and parsed["header"].get("typ") == DELEGATION_TYP:
|
|
537
|
+
return {"token": match.group(1), "via": "authorization"}
|
|
538
|
+
return None
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
# --- RFC 8785 canonical JSON --------------------------------------------------------------------------------
|
|
542
|
+
|
|
543
|
+
def _number(value: float) -> str:
|
|
544
|
+
"""ECMAScript Number::toString, which RFC 8785 §3.2.2.3 prescribes."""
|
|
545
|
+
if value == 0:
|
|
546
|
+
return "0"
|
|
547
|
+
if value < 0:
|
|
548
|
+
return "-" + _number(-value)
|
|
549
|
+
digits, exponent = _shortest(value)
|
|
550
|
+
k, n = len(digits), len(digits) + exponent
|
|
551
|
+
if k <= n <= 21:
|
|
552
|
+
return digits + "0" * (n - k)
|
|
553
|
+
if 0 < n <= 21:
|
|
554
|
+
return digits[:n] + "." + digits[n:]
|
|
555
|
+
if -6 < n <= 0:
|
|
556
|
+
return "0." + "0" * (-n) + digits
|
|
557
|
+
e = n - 1
|
|
558
|
+
mantissa = digits if k == 1 else digits[0] + "." + digits[1:]
|
|
559
|
+
return "%se%s%d" % (mantissa, "+" if e >= 0 else "-", abs(e))
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _shortest(value: float) -> Tuple[str, int]:
|
|
563
|
+
"""The shortest round-trip digits of a positive finite double (Python's repr is shortest round-trip)."""
|
|
564
|
+
text = repr(value)
|
|
565
|
+
mantissa, _, exp = text.partition("e")
|
|
566
|
+
whole, _, frac = mantissa.partition(".")
|
|
567
|
+
digits = (whole + frac).lstrip("0")
|
|
568
|
+
exponent = (int(exp) if exp else 0) - len(frac)
|
|
569
|
+
if not digits:
|
|
570
|
+
return "0", 0
|
|
571
|
+
stripped = digits.rstrip("0")
|
|
572
|
+
exponent += len(digits) - len(stripped)
|
|
573
|
+
return stripped, exponent
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
_ESCAPES = {'"': '\\"', "\\": "\\\\", "\b": "\\b", "\f": "\\f", "\n": "\\n", "\r": "\\r", "\t": "\\t"}
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _string(value: str) -> str:
|
|
580
|
+
out = ['"']
|
|
581
|
+
for ch in value:
|
|
582
|
+
code = ord(ch)
|
|
583
|
+
if 0xD800 <= code <= 0xDFFF:
|
|
584
|
+
raise TypeError("a lone surrogate has no canonical form")
|
|
585
|
+
if ch in _ESCAPES:
|
|
586
|
+
out.append(_ESCAPES[ch])
|
|
587
|
+
elif code < 0x20:
|
|
588
|
+
out.append("\\u%04x" % code)
|
|
589
|
+
else:
|
|
590
|
+
out.append(ch)
|
|
591
|
+
out.append('"')
|
|
592
|
+
return "".join(out)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def jcs(value: Any) -> str:
|
|
596
|
+
"""RFC 8785: members sorted by UTF-16 code units, numbers in ECMAScript form, strings as JSON.stringify
|
|
597
|
+
escapes them. Refuses what has no canonical form: a non-finite number, a lone surrogate, a non-JSON value.
|
|
598
|
+
A JSON number is a double: an integer beyond 2**53 is written as JavaScript would write it."""
|
|
599
|
+
if value is None:
|
|
600
|
+
return "null"
|
|
601
|
+
if value is True:
|
|
602
|
+
return "true"
|
|
603
|
+
if value is False:
|
|
604
|
+
return "false"
|
|
605
|
+
if isinstance(value, int):
|
|
606
|
+
if abs(value) < 2 ** 53:
|
|
607
|
+
return str(value)
|
|
608
|
+
try:
|
|
609
|
+
return _number(float(value))
|
|
610
|
+
except OverflowError:
|
|
611
|
+
raise TypeError("a number beyond a double has no canonical form") from None
|
|
612
|
+
if isinstance(value, float):
|
|
613
|
+
if not math.isfinite(value):
|
|
614
|
+
raise TypeError("a non-finite number has no canonical form")
|
|
615
|
+
return _number(value)
|
|
616
|
+
if isinstance(value, str):
|
|
617
|
+
return _string(value)
|
|
618
|
+
if isinstance(value, list):
|
|
619
|
+
return "[" + ",".join(jcs(item) for item in value) + "]"
|
|
620
|
+
if isinstance(value, dict):
|
|
621
|
+
if not all(isinstance(key, str) for key in value):
|
|
622
|
+
raise TypeError("only string member names have a canonical form")
|
|
623
|
+
keys = sorted(value, key=lambda key: key.encode("utf-16-be", "surrogatepass"))
|
|
624
|
+
return "{" + ",".join(_string(key) + ":" + jcs(value[key]) for key in keys) + "}"
|
|
625
|
+
raise TypeError("%s has no canonical form" % type(value).__name__)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def action_digest(action: Any) -> str:
|
|
629
|
+
"""The digest an approval binds: sha256: and the hex SHA-256 of the action's RFC 8785 form."""
|
|
630
|
+
return "sha256:" + hashlib.sha256(jcs(action).encode("utf-8")).hexdigest()
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
# --- the estate's keys ----------------------------------------------------------------------------------------
|
|
634
|
+
|
|
635
|
+
def _loopback(host: str) -> bool:
|
|
636
|
+
if host == "localhost":
|
|
637
|
+
return True
|
|
638
|
+
try:
|
|
639
|
+
return ipaddress.ip_address(host).is_loopback
|
|
640
|
+
except ValueError:
|
|
641
|
+
return False
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
645
|
+
"""A redirect could leave https or loopback for any scheme or host: none is followed."""
|
|
646
|
+
|
|
647
|
+
def redirect_request(self, *args: Any, **kwargs: Any) -> None: # noqa: ARG002
|
|
648
|
+
return None
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
_OPENER = urllib.request.build_opener(_NoRedirect)
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _fetch(source: str) -> Any:
|
|
655
|
+
if source.startswith(("https://", "http://")):
|
|
656
|
+
request = urllib.request.Request(source, headers={"Accept": "application/json"})
|
|
657
|
+
with _OPENER.open(request, timeout=5) as response: # https or loopback http only, no redirect
|
|
658
|
+
return json.loads(response.read(65537)[:65536])
|
|
659
|
+
return json.loads(Path(source).expanduser().read_text())
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
class JwksCache:
|
|
663
|
+
"""The estate's key set from `auth.delegation.jwks` (https, http on loopback, or a file), cached as DL-15
|
|
664
|
+
says: at most 10 minutes, re-read at most once a minute for a kid it does not hold. `get()` answers None
|
|
665
|
+
when no set younger than 10 minutes can be had, which the verifier reports as issuer-unavailable."""
|
|
666
|
+
|
|
667
|
+
def __init__(self, source: str, *, fetch: Optional[Callable[[str], Any]] = None, clock: Callable[[], float] = time.time):
|
|
668
|
+
if not isinstance(source, str) or not source:
|
|
669
|
+
raise ValueError("the key set needs a source")
|
|
670
|
+
if source.startswith(("https://", "http://")):
|
|
671
|
+
parts = urllib.parse.urlsplit(source)
|
|
672
|
+
if parts.username or parts.password:
|
|
673
|
+
raise ValueError("a key set URL carries no credentials")
|
|
674
|
+
if parts.scheme == "http" and not _loopback(parts.hostname or ""):
|
|
675
|
+
raise ValueError("a key set is read over https, or over http on loopback only")
|
|
676
|
+
elif "://" in source:
|
|
677
|
+
raise ValueError("a key set is an https URL, a loopback http URL or a file path")
|
|
678
|
+
self.source = source
|
|
679
|
+
self._fetch = fetch or _fetch
|
|
680
|
+
self._clock = clock
|
|
681
|
+
self._keys: Optional[Dict[str, Any]] = None
|
|
682
|
+
self._read_at = -math.inf
|
|
683
|
+
self._tried_at = -math.inf
|
|
684
|
+
self._reread_at = -math.inf # the last re-read for a kid the set did not hold
|
|
685
|
+
self._lock = threading.Lock()
|
|
686
|
+
|
|
687
|
+
def _read(self, now: float) -> None:
|
|
688
|
+
self._tried_at = now
|
|
689
|
+
try:
|
|
690
|
+
value = self._fetch(self.source)
|
|
691
|
+
except (OSError, ValueError):
|
|
692
|
+
return
|
|
693
|
+
if _has_keys(value):
|
|
694
|
+
self._keys, self._read_at = value, now
|
|
695
|
+
|
|
696
|
+
def get(self, kid: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
|
697
|
+
with self._lock:
|
|
698
|
+
now = self._clock()
|
|
699
|
+
fresh = now - self._read_at < JWKS_MAX_AGE_SECONDS
|
|
700
|
+
known = self._keys is not None and (kid is None or any(isinstance(k, dict) and k.get("kid") == kid for k in self._keys["keys"]))
|
|
701
|
+
if not fresh:
|
|
702
|
+
if now - self._tried_at >= JWKS_RETRY_SECONDS:
|
|
703
|
+
self._read(now)
|
|
704
|
+
elif not known and now - self._reread_at >= JWKS_REREAD_SECONDS:
|
|
705
|
+
self._reread_at = now
|
|
706
|
+
self._read(now)
|
|
707
|
+
return self._keys if now - self._read_at < JWKS_MAX_AGE_SECONDS else None
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _kid_of(token: str) -> Optional[str]:
|
|
711
|
+
parsed = _parse_compact(token)
|
|
712
|
+
kid = parsed.get("header", {}).get("kid") if "problem" not in parsed else None
|
|
713
|
+
return kid if isinstance(kid, str) else None
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
# --- the gate in front of a service's tools ------------------------------------------------------------------
|
|
717
|
+
|
|
718
|
+
def delegation_gate(*, audience: str, jwks: Any, effects: Mapping[str, str], issuer: Optional[str] = None,
|
|
719
|
+
read_only: bool = False, clock: Callable[[], float] = time.time) -> Callable[[str, Any], Dict[str, Any]]:
|
|
720
|
+
"""A check for one tools/call: `gate(tool, headers)` answers the accepted delegation or a DL-19 refusal.
|
|
721
|
+
`effects` maps each tool to its declared effect class (its manifest entry); a tool with none is refused
|
|
722
|
+
rather than guessed. `jwks` is a JwksCache or a key set."""
|
|
723
|
+
|
|
724
|
+
def gate(tool: str, headers: Any) -> Dict[str, Any]:
|
|
725
|
+
effect = effects.get(tool)
|
|
726
|
+
if effect not in EFFECT_CLASSES:
|
|
727
|
+
return _refuse("unknown-state", "no-declared-effect")
|
|
728
|
+
carried = read_delegation_token(headers)
|
|
729
|
+
if carried is None:
|
|
730
|
+
return _refuse("unauthenticated", "no-token")
|
|
731
|
+
keys = jwks.get(_kid_of(carried["token"])) if isinstance(jwks, JwksCache) else jwks
|
|
732
|
+
return verify_delegation(carried["token"], audience=audience, jwks=keys, now=clock(), capability=tool,
|
|
733
|
+
effect=effect, issuer=issuer, read_only=read_only)
|
|
734
|
+
|
|
735
|
+
return gate
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def refusal_error(refusal: Dict[str, Any]) -> Dict[str, Any]:
|
|
739
|
+
"""The DL-19 body: {error: {code, message, data: {detail, ...}}}; never a token or a key."""
|
|
740
|
+
data = {"detail": refusal["detail"]}
|
|
741
|
+
data.update(refusal.get("data") or {})
|
|
742
|
+
return {"error": {"code": refusal["code"], "message": REFUSAL_MESSAGES.get(refusal["code"], "Refused."), "data": data}}
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def refusal_tool_result(refusal: Dict[str, Any], traceparent: Optional[str] = None) -> Dict[str, Any]:
|
|
746
|
+
"""An MCP tools/call result: the refusal as structuredContent with isError true (DL-19)."""
|
|
747
|
+
structured = refusal_error(refusal)
|
|
748
|
+
result: Dict[str, Any] = {"resultType": "complete", "content": [{"type": "text", "text": json.dumps(structured, ensure_ascii=False)}],
|
|
749
|
+
"structuredContent": structured, "isError": True}
|
|
750
|
+
if traceparent:
|
|
751
|
+
result["_meta"] = {"traceparent": traceparent}
|
|
752
|
+
return result
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def refusal_http(refusal: Dict[str, Any]) -> Tuple[int, Dict[str, str], bytes]:
|
|
756
|
+
"""An HTTP route's answer: status, headers and JSON body; a 401 names the bearer scheme (RFC 6750 §3)."""
|
|
757
|
+
status = REFUSAL_STATUS.get(refusal["code"], 403)
|
|
758
|
+
headers = {"Content-Type": "application/json", "Cache-Control": "no-store"}
|
|
759
|
+
if status == 401:
|
|
760
|
+
headers["WWW-Authenticate"] = 'Bearer error="invalid_token"'
|
|
761
|
+
return status, headers, json.dumps(refusal_error(refusal)).encode()
|