@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.41.0

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.
Files changed (123) hide show
  1. package/CHANGELOG.md +203 -0
  2. package/README.md +18 -6
  3. package/SECURITY.md +27 -6
  4. package/bin/fabric-agent-adapter.js +71 -6
  5. package/package.json +2 -2
  6. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +42 -66
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +42 -33
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +104 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +465 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +5 -4
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +129 -0
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +48 -19
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +11 -184
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +288 -0
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +848 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +7 -18
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +15 -46
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +11 -68
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +50 -16
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +235 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +146 -32
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +404 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +408 -0
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +91 -0
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +882 -0
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -39
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +336 -0
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +113 -222
  82. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
  83. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +44 -405
  84. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +140 -0
  85. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
  86. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
  87. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +119 -77
  88. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
  89. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
  90. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
  91. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
  92. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
  93. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +285 -109
  94. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
  95. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
  96. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +32 -39
  97. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
  98. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
  99. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
  100. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
  101. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +437 -0
  102. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
  103. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
  104. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1621 -0
  105. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
  106. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
  107. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
  108. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
  109. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
  110. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
  111. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
  112. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
  113. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +26 -13
  114. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +122 -1392
  115. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +278 -0
  116. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +539 -0
  117. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +324 -0
  118. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +173 -0
  119. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +491 -0
  120. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +11 -1
  121. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
  122. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
  123. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +2 -1
@@ -16,8 +16,8 @@ R compared by its encoding).
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
- import base64
20
19
  import hashlib
20
+ import http.client
21
21
  import ipaddress
22
22
  import json
23
23
  import math
@@ -31,6 +31,21 @@ from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple
31
31
  import urllib.error
32
32
  import urllib.parse
33
33
  import urllib.request
34
+ import sys as _sys # noqa: E402
35
+ from pathlib import Path as _Path # noqa: E402
36
+ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
37
+ _sys.path.insert(0, str(_Path(__file__).resolve().parent))
38
+ from fabric_crypto import ( # noqa: E402 - the core (FAA-65)
39
+ SMALL_ORDER_ENCODINGS, # noqa: F401 - public here since v0.39.1 (test/fixtures/kit-api)
40
+ b64url as _b64encode,
41
+ b64url_decode as _b64decode,
42
+ ed25519_verify,
43
+ is_small_order_key,
44
+ )
45
+ from fabric_jsvalue import ( # noqa: E402 - the core (FAA-65)
46
+ MISSING as _MISSING,
47
+ jcs,
48
+ )
34
49
 
35
50
  REFUSAL_CODES = (
36
51
  "unauthenticated", "token-expired", "wrong-audience", "scope-not-granted", "effect-above-ceiling", "no-grant",
@@ -50,6 +65,8 @@ JWKS_MAX_AGE_SECONDS = 600
50
65
  JWKS_REREAD_SECONDS = 60
51
66
  # After a failed read, wait this long before trying again, so a down issuer does not cost every call a fetch.
52
67
  JWKS_RETRY_SECONDS = 10
68
+ # One read of the key set, headers and body together, ends within this many seconds (Node's AbortSignal.timeout(5000)).
69
+ JWKS_FETCH_SECONDS = 5.0
53
70
  # JavaScript's String.prototype.trim set, so a header is trimmed as the contract's verifier trims it.
54
71
  _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
72
 
@@ -76,31 +93,15 @@ REFUSAL_MESSAGES = {
76
93
  "unknown-state": "The state cannot be established, so the call is refused.",
77
94
  }
78
95
 
79
- _B64URL = re.compile(r"^[A-Za-z0-9_-]+$")
96
+ _B64URL = re.compile(r"^[A-Za-z0-9_-]+\Z")
80
97
  # DL-2's grammar, as the contract's verifier types the claims (DEC-0045): an agent id, an opaque session or task id,
81
98
  # a URI, a jti and a scope.
82
- AGENT_ID = re.compile(r"^[a-z][a-z0-9-]{1,62}(\.[a-z][a-z0-9-]{0,31})?$")
83
- OPAQUE_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
84
- _URI = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:\S+$")
85
- _JTI = re.compile(r"^[A-Za-z0-9._~-]{16,128}$")
86
- _SCOPE = re.compile(r"^[a-z][a-z0-9._-]{1,127}( [a-z][a-z0-9._-]{1,127})*$")
99
+ AGENT_ID = re.compile(r"^[a-z][a-z0-9-]{1,62}(\.[a-z][a-z0-9-]{0,31})?\Z")
100
+ OPAQUE_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}\Z")
101
+ _URI = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:\S+\Z")
102
+ _JTI = re.compile(r"^[A-Za-z0-9._~-]{16,128}\Z")
103
+ _SCOPE = re.compile(r"^[a-z][a-z0-9._-]{1,127}( [a-z][a-z0-9._-]{1,127})*\Z")
87
104
  _SPEND_WINDOWS = ("total", "task", "project", "day", "week", "month")
88
- # The small-order points of edwards25519 and their non-canonical encodings, sign bit cleared (the contract's
89
- # src/ed25519.ts; libsodium's has_small_order list).
90
- SMALL_ORDER_ENCODINGS = (
91
- "0000000000000000000000000000000000000000000000000000000000000000", "0100000000000000000000000000000000000000000000000000000000000000",
92
- "26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05", "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a",
93
- "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f",
94
- "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f",
95
- )
96
- _MISSING = object()
97
-
98
-
99
- def is_small_order_key(raw: bytes) -> bool:
100
- """Whether a 32-byte Ed25519 public key is a point of small order, in any encoding (CT-29)."""
101
- if len(raw) != 32:
102
- return True
103
- return (raw[:31] + bytes([raw[31] & 0x7F])).hex() in SMALL_ORDER_ENCODINGS
104
105
 
105
106
 
106
107
  def _full(pattern: "re.Pattern[str]", value: Any) -> bool:
@@ -128,90 +129,9 @@ def _is_time(value: Any) -> bool:
128
129
 
129
130
  # --- Ed25519 (RFC 8032 §5.1) ----------------------------------------------------------------------------
130
131
 
131
- _P = 2 ** 255 - 19
132
- _L = 2 ** 252 + 27742317777372353535851937790883648493
133
- _D = (-121665 * pow(121666, _P - 2, _P)) % _P
134
- _SQRT_M1 = pow(2, (_P - 1) // 4, _P)
135
-
136
-
137
- def _add(a: Tuple[int, int, int, int], b: Tuple[int, int, int, int]) -> Tuple[int, int, int, int]:
138
- x1, y1, z1, t1 = a
139
- x2, y2, z2, t2 = b
140
- aa = (y1 - x1) * (y2 - x2) % _P
141
- bb = (y1 + x1) * (y2 + x2) % _P
142
- cc = 2 * t1 * t2 * _D % _P
143
- dd = 2 * z1 * z2 % _P
144
- e, f, g, h = bb - aa, dd - cc, dd + cc, bb + aa
145
- return (e * f % _P, g * h % _P, f * g % _P, e * h % _P)
146
-
147
-
148
- def _mul(scalar: int, point: Tuple[int, int, int, int]) -> Tuple[int, int, int, int]:
149
- result = (0, 1, 1, 0)
150
- while scalar:
151
- if scalar & 1:
152
- result = _add(result, point)
153
- point = _add(point, point)
154
- scalar >>= 1
155
- return result
156
-
157
-
158
- def _decode_point(data: bytes) -> Optional[Tuple[int, int, int, int]]:
159
- if len(data) != 32:
160
- return None
161
- y = int.from_bytes(data, "little")
162
- sign = y >> 255
163
- y &= (1 << 255) - 1
164
- if y >= _P:
165
- return None
166
- x2 = (y * y - 1) * pow(_D * y * y + 1, _P - 2, _P) % _P
167
- if x2 == 0:
168
- if sign:
169
- return None
170
- return (0, y, 1, 0)
171
- x = pow(x2, (_P + 3) // 8, _P)
172
- if (x * x - x2) % _P:
173
- x = x * _SQRT_M1 % _P
174
- if (x * x - x2) % _P:
175
- return None
176
- if x & 1 != sign:
177
- x = _P - x
178
- return (x, y, 1, x * y % _P)
179
-
180
-
181
- def _encode_point(point: Tuple[int, int, int, int]) -> bytes:
182
- x, y, z, _ = point
183
- zi = pow(z, _P - 2, _P)
184
- x, y = x * zi % _P, y * zi % _P
185
- return (y | ((x & 1) << 255)).to_bytes(32, "little")
186
-
187
-
188
- _G = _decode_point(bytes.fromhex("5866666666666666666666666666666666666666666666666666666666666666"))
189
- _IDENTITY = (1).to_bytes(32, "little")
190
-
191
-
192
- def ed25519_verify(public_key: bytes, message: bytes, signature: bytes) -> bool:
193
- """RFC 8032 §5.1.7, cofactorless: [S]B == R + [k]A, compared by R's encoding. False, never an exception."""
194
- if len(public_key) != 32 or len(signature) != 64:
195
- return False
196
- s = int.from_bytes(signature[32:], "little")
197
- if s >= _L:
198
- return False
199
- a = _decode_point(public_key)
200
- if a is None or _encode_point(_mul(8, a)) == _IDENTITY:
201
- return False # a small-order key would accept R = [S]B for a quarter or more of all messages; OpenSSL refuses it
202
- k = int.from_bytes(hashlib.sha512(signature[:32] + public_key + message).digest(), "little") % _L
203
- neg_a = ((-a[0]) % _P, a[1], a[2], (-a[3]) % _P)
204
- return secrets.compare_digest(_encode_point(_add(_mul(s, _G), _mul(k, neg_a))), signature[:32])
205
-
206
132
 
207
133
  # --- the compact document ------------------------------------------------------------------------------
208
134
 
209
- def _b64decode(segment: str) -> bytes:
210
- """Base64url without padding, as Node reads it: a dangling sixth bit group is ignored."""
211
- if len(segment) % 4 == 1:
212
- segment = segment[:-1]
213
- return base64.urlsafe_b64decode(segment + "=" * (-len(segment) % 4))
214
-
215
135
 
216
136
  class _Duplicate(ValueError):
217
137
  pass
@@ -305,7 +225,8 @@ def _key_for(jwks: Dict[str, Any], kid: str) -> Dict[str, Any]:
305
225
  raw = _b64decode(jwk["x"])
306
226
  except ValueError:
307
227
  return {"problem": "unusable-key"}
308
- if len(raw) != 32:
228
+ # D4: exactly the 43 characters that write 32 bytes, so the key compared with the estate record is one string.
229
+ if len(raw) != 32 or _b64encode(raw) != jwk["x"]:
309
230
  return {"problem": "unusable-key"}
310
231
  if is_small_order_key(raw):
311
232
  return {"problem": "small-order-key"} # refused before any verification (DEC-0045, CT-29)
@@ -460,24 +381,27 @@ def _readable_clock(now: Any) -> bool:
460
381
  return False
461
382
 
462
383
 
463
- def _estate_problem(estate: Any, kid: str, x: str, now: float, applied: Any = None,
464
- revocations: Any = None) -> Optional[Tuple[str, str]]:
384
+ def _estate_problem(estate: Any, kid: str, x: str, now: float, applied: Any = _MISSING,
385
+ revocations: Any = _MISSING) -> Optional[Tuple[str, str]]:
465
386
  """DL-14 row 4a (DEC-0055, DEC-0060), right after the signature: when the callee reads the estate record, the
466
387
  token's kid and the published key must be certified by that revision. `estate` is a revision the caller verified
467
- (fabric_estate.verify_estate_record) and its machine applied, or a callable returning it; None skips the rule (a
468
- callable returning None is an unreadable source). `applied` is the highest revision the machine applied
469
- (`{"revision", "digest"?}`); `revocations` the revocation delta's entries applied since (a list or a callable). A
470
- source that raises is unreadable, never a pass. Returns ("token" | "issuer", detail) or None."""
471
- if estate is None:
388
+ (fabric_estate.verify_estate_record) and its machine applied, or a callable returning it; only an omitted `estate`
389
+ skips the rule, and None (given, or returned by the callable) is an unreadable source, as Node's null is (D3).
390
+ `applied` is the highest revision the machine applied (`{"revision", "digest"?}`); `revocations` the revocation
391
+ delta's entries applied since (a list or a callable). An omitted one is absent; None is unreadable. A source that
392
+ raises is unreadable, never a pass. Returns ("token" | "issuer", detail) or None."""
393
+ if estate is _MISSING:
472
394
  return None
473
395
  try:
474
396
  record = estate() if callable(estate) else estate
475
397
  extra = revocations() if callable(revocations) else revocations
476
398
  except Exception:
477
399
  return ("issuer", "estate-unreadable")
400
+ if applied is None: # the contract's hubKeyCertified refuses a null applied mark after its record checks: unreadable
401
+ return ("issuer", "estate-unreadable")
478
402
  import fabric_estate # beside this file; imported here, since fabric_estate imports this module
479
- verdict = fabric_estate.hub_key_certified(record, kid, x, now, applied,
480
- fabric_estate._MISSING if extra is None else extra)
403
+ given = {} if applied is _MISSING else {"applied": applied}
404
+ verdict = fabric_estate.hub_key_certified(record, kid, x, now, extra_revocations=extra, **given)
481
405
  if verdict["certified"]:
482
406
  return None
483
407
  # The clock was refused before row 1, so a verdict here is the token's or the trust source's.
@@ -485,8 +409,8 @@ def _estate_problem(estate: Any, kid: str, x: str, now: float, applied: Any = No
485
409
 
486
410
 
487
411
  def verify_delegation(token: Any, *, audience: str, jwks: Any, now: float, capability: str, effect: str,
488
- issuer: Optional[str], read_only: bool = False, estate: Any = None,
489
- estate_applied: Any = None, estate_revocations: Any = None) -> Dict[str, Any]:
412
+ issuer: Optional[str], read_only: bool = False, estate: Any = _MISSING,
413
+ estate_applied: Any = _MISSING, estate_revocations: Any = _MISSING) -> Dict[str, Any]:
490
414
  """DL-13, in its fixed order: nothing about a token is believed before its signature is.
491
415
  `now` is seconds since the epoch. Returns {ok: True, claims, principal, caller, chain, kid} or a refusal
492
416
  {ok: False, code, detail[, data]} whose detail is a short machine word, never the token. `issuer` is required.
@@ -606,8 +530,8 @@ UNSAFE_NO_JTI_STORE = _NoReplayProtection()
606
530
 
607
531
 
608
532
  def verify_approval(approval: Optional[str], *, audience: str, jwks: Any, now: float, action_digest: str,
609
- jti_store: Any, issuer: Optional[str], estate: Any = None, estate_applied: Any = None,
610
- estate_revocations: Any = None) -> Dict[str, Any]:
533
+ jti_store: Any, issuer: Optional[str], estate: Any = _MISSING, estate_applied: Any = _MISSING,
534
+ estate_revocations: Any = _MISSING) -> Dict[str, Any]:
611
535
  """DL-18: a missing approval is approval-required carrying the action digest; any fault of the document is
612
536
  approval-invalid; a second use is approval-reused. The jti is consumed last."""
613
537
  if approval is None or approval == "":
@@ -681,90 +605,6 @@ def read_delegation_token(headers: Any) -> Optional[Dict[str, str]]:
681
605
 
682
606
  # --- RFC 8785 canonical JSON --------------------------------------------------------------------------------
683
607
 
684
- def _number(value: float) -> str:
685
- """ECMAScript Number::toString, which RFC 8785 §3.2.2.3 prescribes."""
686
- if value == 0:
687
- return "0"
688
- if value < 0:
689
- return "-" + _number(-value)
690
- digits, exponent = _shortest(value)
691
- k, n = len(digits), len(digits) + exponent
692
- if k <= n <= 21:
693
- return digits + "0" * (n - k)
694
- if 0 < n <= 21:
695
- return digits[:n] + "." + digits[n:]
696
- if -6 < n <= 0:
697
- return "0." + "0" * (-n) + digits
698
- e = n - 1
699
- mantissa = digits if k == 1 else digits[0] + "." + digits[1:]
700
- return "%se%s%d" % (mantissa, "+" if e >= 0 else "-", abs(e))
701
-
702
-
703
- def _shortest(value: float) -> Tuple[str, int]:
704
- """The shortest round-trip digits of a positive finite double (Python's repr is shortest round-trip)."""
705
- text = repr(value)
706
- mantissa, _, exp = text.partition("e")
707
- whole, _, frac = mantissa.partition(".")
708
- digits = (whole + frac).lstrip("0")
709
- exponent = (int(exp) if exp else 0) - len(frac)
710
- if not digits:
711
- return "0", 0
712
- stripped = digits.rstrip("0")
713
- exponent += len(digits) - len(stripped)
714
- return stripped, exponent
715
-
716
-
717
- _ESCAPES = {'"': '\\"', "\\": "\\\\", "\b": "\\b", "\f": "\\f", "\n": "\\n", "\r": "\\r", "\t": "\\t"}
718
-
719
-
720
- def _string(value: str) -> str:
721
- out = ['"']
722
- for ch in value:
723
- code = ord(ch)
724
- if 0xD800 <= code <= 0xDFFF:
725
- raise TypeError("a lone surrogate has no canonical form")
726
- if ch in _ESCAPES:
727
- out.append(_ESCAPES[ch])
728
- elif code < 0x20:
729
- out.append("\\u%04x" % code)
730
- else:
731
- out.append(ch)
732
- out.append('"')
733
- return "".join(out)
734
-
735
-
736
- def jcs(value: Any) -> str:
737
- """RFC 8785: members sorted by UTF-16 code units, numbers in ECMAScript form, strings as JSON.stringify
738
- escapes them. Refuses what has no canonical form: a non-finite number, a lone surrogate, a non-JSON value.
739
- A JSON number is a double: an integer beyond 2**53 is written as JavaScript would write it."""
740
- if value is None:
741
- return "null"
742
- if value is True:
743
- return "true"
744
- if value is False:
745
- return "false"
746
- if isinstance(value, int):
747
- if abs(value) < 2 ** 53:
748
- return str(value)
749
- try:
750
- return _number(float(value))
751
- except OverflowError:
752
- raise TypeError("a number beyond a double has no canonical form") from None
753
- if isinstance(value, float):
754
- if not math.isfinite(value):
755
- raise TypeError("a non-finite number has no canonical form")
756
- return _number(value)
757
- if isinstance(value, str):
758
- return _string(value)
759
- if isinstance(value, list):
760
- return "[" + ",".join(jcs(item) for item in value) + "]"
761
- if isinstance(value, dict):
762
- if not all(isinstance(key, str) for key in value):
763
- raise TypeError("only string member names have a canonical form")
764
- keys = sorted(value, key=lambda key: key.encode("utf-16-be", "surrogatepass"))
765
- return "{" + ",".join(_string(key) + ":" + jcs(value[key]) for key in keys) + "}"
766
- raise TypeError("%s has no canonical form" % type(value).__name__)
767
-
768
608
 
769
609
  def action_digest(action: Any) -> str:
770
610
  """The digest an approval binds: sha256: and the hex SHA-256 of the action's RFC 8785 form."""
@@ -789,18 +629,60 @@ class _NoRedirect(urllib.request.HTTPRedirectHandler):
789
629
  return None
790
630
 
791
631
 
792
- _OPENER = urllib.request.build_opener(_NoRedirect)
632
+ def _opener(deadline: Dict[str, Any]) -> urllib.request.OpenerDirector:
633
+ """No redirect, and a timer that shuts the socket down at the deadline: a socket timeout bounds one read, so an
634
+ issuer trickling a byte a second would otherwise hold the caller for as long as it likes (finding 13)."""
635
+ def watched(base: type) -> type:
636
+ class Watched(base): # type: ignore[misc, valid-type]
637
+ def connect(self) -> None:
638
+ super().connect()
639
+ sock = self.sock
640
+
641
+ def fire() -> None:
642
+ deadline["fired"] = True
643
+ try:
644
+ sock.shutdown(2) # socket.SHUT_RDWR
645
+ except OSError:
646
+ pass
647
+ timer = threading.Timer(max(0.0, deadline["end"] - time.monotonic()), fire)
648
+ timer.daemon = True
649
+ deadline["timer"] = timer
650
+ timer.start()
651
+ return Watched
652
+
653
+ class Http(urllib.request.HTTPHandler):
654
+ def http_open(self, req: Any) -> Any:
655
+ return self.do_open(watched(http.client.HTTPConnection), req)
656
+
657
+ class Https(urllib.request.HTTPSHandler):
658
+ def https_open(self, req: Any) -> Any:
659
+ return self.do_open(watched(http.client.HTTPSConnection), req, context=self._context)
660
+ return urllib.request.build_opener(_NoRedirect, Http, Https)
793
661
 
794
662
 
795
663
  def _fetch(source: str) -> Any:
796
664
  if source.startswith(("https://", "http://")):
797
665
  request = urllib.request.Request(source, headers={"Accept": "application/json"})
666
+ deadline: Dict[str, Any] = {"end": time.monotonic() + JWKS_FETCH_SECONDS, "fired": False, "timer": None}
798
667
  try:
799
- with _OPENER.open(request, timeout=5) as response: # https or loopback http only, no redirect
800
- return json.loads(response.read(65537)[:65536])
668
+ with _opener(deadline).open(request, timeout=JWKS_FETCH_SECONDS) as response: # https or loopback http only
669
+ body = b""
670
+ while len(body) <= 65536 and not deadline["fired"]:
671
+ chunk = response.read1(65537 - len(body))
672
+ if not chunk:
673
+ break
674
+ body += chunk
675
+ if deadline["fired"] or time.monotonic() > deadline["end"]:
676
+ raise TimeoutError("the key set did not arrive within %s s" % JWKS_FETCH_SECONDS)
677
+ return json.loads(body[:65536])
801
678
  except urllib.error.HTTPError as exc:
802
679
  exc.close()
803
680
  raise
681
+ except http.client.HTTPException as exc: # a garbage status line or headers: the issuer cannot be read
682
+ raise ConnectionError(type(exc).__name__) from None
683
+ finally:
684
+ if deadline["timer"] is not None:
685
+ deadline["timer"].cancel()
804
686
  return json.loads(Path(source).expanduser().read_text())
805
687
 
806
688
 
@@ -828,28 +710,37 @@ class JwksCache:
828
710
  self._tried_at = -math.inf
829
711
  self._reread_at = -math.inf # the last re-read for a kid the set did not hold
830
712
  self._lock = threading.Lock()
831
-
832
- def _read(self, now: float) -> None:
833
- self._tried_at = now
834
- try:
835
- value = self._fetch(self.source)
836
- except (OSError, ValueError):
837
- return
838
- if _has_keys(value):
839
- self._keys, self._read_at = value, now
713
+ self._pending: Optional[threading.Event] = None
840
714
 
841
715
  def get(self, kid: Optional[str] = None) -> Optional[Dict[str, Any]]:
716
+ """The key set, or None. The read runs outside the lock (finding 13): a caller whose kid the set holds never
717
+ waits on another caller's fetch, and one read at a time goes out, as Node's `pending` does."""
718
+ leader, waiting = False, None
842
719
  with self._lock:
843
720
  now = self._clock()
844
721
  fresh = now - self._read_at < JWKS_MAX_AGE_SECONDS
845
722
  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"]))
846
- if not fresh:
847
- if now - self._tried_at >= JWKS_RETRY_SECONDS:
848
- self._read(now)
849
- elif not known and now - self._reread_at >= JWKS_REREAD_SECONDS:
723
+ due = (not fresh and now - self._tried_at >= JWKS_RETRY_SECONDS) or (fresh and not known and now - self._reread_at >= JWKS_REREAD_SECONDS)
724
+ if due and fresh:
850
725
  self._reread_at = now
851
- self._read(now)
852
- return self._keys if now - self._read_at < JWKS_MAX_AGE_SECONDS else None
726
+ if due and self._pending is None:
727
+ self._pending, self._tried_at, leader = threading.Event(), now, True
728
+ elif due:
729
+ waiting = self._pending
730
+ if leader:
731
+ try:
732
+ value = self._fetch(self.source)
733
+ except Exception: # noqa: BLE001 - whatever a source raises leaves the set unread; the age check decides
734
+ value = None
735
+ with self._lock:
736
+ if _has_keys(value):
737
+ self._keys, self._read_at = value, now
738
+ done, self._pending = self._pending, None
739
+ done.set()
740
+ elif waiting is not None:
741
+ waiting.wait(JWKS_FETCH_SECONDS + 1)
742
+ with self._lock:
743
+ return self._keys if self._clock() - self._read_at < JWKS_MAX_AGE_SECONDS else None
853
744
 
854
745
 
855
746
  def _kid_of(token: str) -> Optional[str]:
@@ -864,8 +755,8 @@ TIERS = ("T0", "T1", "T2")
864
755
 
865
756
 
866
757
  def delegation_gate(*, audience: str, jwks: Any, effects: Mapping[str, str], issuer: str,
867
- read_only: bool = False, clock: Callable[[], float] = time.time, estate: Any = None,
868
- estate_applied: Any = None, estate_revocations: Any = None) -> Callable[..., Dict[str, Any]]:
758
+ read_only: bool = False, clock: Callable[[], float] = time.time, estate: Any = _MISSING,
759
+ estate_applied: Any = _MISSING, estate_revocations: Any = _MISSING) -> Callable[..., Dict[str, Any]]:
869
760
  """A check for one tools/call: `gate(tool, headers, tier="T2")` answers the accepted delegation or a DL-19
870
761
  refusal. `effects` maps each tool to its declared effect class (its manifest entry); a tool with none is
871
762
  refused rather than guessed. `jwks` is a JwksCache or a key set; `issuer` (auth.delegation.issuer) is required.