hypermind 0.11.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. hmctl/__init__.py +7 -0
  2. hmctl/cli.py +947 -0
  3. hypermind/__init__.py +208 -0
  4. hypermind/_deprecations.py +108 -0
  5. hypermind/agent.py +2896 -0
  6. hypermind/agent_card.py +127 -0
  7. hypermind/api/__init__.py +32 -0
  8. hypermind/api/app.py +686 -0
  9. hypermind/capability.py +923 -0
  10. hypermind/consult.py +493 -0
  11. hypermind/consult_bias.py +112 -0
  12. hypermind/contract_net.py +219 -0
  13. hypermind/crypto/__init__.py +21 -0
  14. hypermind/crypto/cose_encrypt.py +126 -0
  15. hypermind/crypto/dkg.py +246 -0
  16. hypermind/crypto/domain.py +59 -0
  17. hypermind/crypto/frost.py +771 -0
  18. hypermind/crypto/hashing.py +38 -0
  19. hypermind/crypto/hlc.py +163 -0
  20. hypermind/crypto/hpke.py +277 -0
  21. hypermind/crypto/kms.py +196 -0
  22. hypermind/crypto/kms_backends/__init__.py +56 -0
  23. hypermind/crypto/kms_backends/aws.py +128 -0
  24. hypermind/crypto/kms_backends/azure.py +114 -0
  25. hypermind/crypto/kms_backends/gcp.py +97 -0
  26. hypermind/crypto/kms_backends/vault.py +120 -0
  27. hypermind/crypto/namespace_root.py +204 -0
  28. hypermind/crypto/pq.py +152 -0
  29. hypermind/crypto/room_epoch.py +97 -0
  30. hypermind/crypto/signing.py +245 -0
  31. hypermind/deliberation.py +355 -0
  32. hypermind/did_web.py +256 -0
  33. hypermind/dispute.py +858 -0
  34. hypermind/errors.py +218 -0
  35. hypermind/eval/__init__.py +49 -0
  36. hypermind/eval/calibration.py +307 -0
  37. hypermind/eval/comparison.py +251 -0
  38. hypermind/eval/replay.py +202 -0
  39. hypermind/eval/swarm_iq.py +718 -0
  40. hypermind/knowledge/__init__.py +17 -0
  41. hypermind/knowledge/citation_graph.py +141 -0
  42. hypermind/knowledge/correlated_agreement.py +167 -0
  43. hypermind/knowledge/embedding_registry.py +115 -0
  44. hypermind/knowledge/kernel_store.py +197 -0
  45. hypermind/knowledge/rekor.py +173 -0
  46. hypermind/knowledge/reputation.py +364 -0
  47. hypermind/knowledge/swarm_memory.py +523 -0
  48. hypermind/knowledge/transparency.py +409 -0
  49. hypermind/mcp/__init__.py +60 -0
  50. hypermind/mcp/bridge.py +151 -0
  51. hypermind/mcp/client.py +142 -0
  52. hypermind/mcp/provenance.py +214 -0
  53. hypermind/mcp/relata_tools.py +152 -0
  54. hypermind/mcp/server.py +248 -0
  55. hypermind/mcp/transport.py +206 -0
  56. hypermind/mind/__init__.py +150 -0
  57. hypermind/mind/active.py +234 -0
  58. hypermind/mind/belief.py +369 -0
  59. hypermind/mind/deliberator.py +625 -0
  60. hypermind/mind/diversity.py +144 -0
  61. hypermind/mind/evolve.py +184 -0
  62. hypermind/mind/federation.py +154 -0
  63. hypermind/mind/goals.py +117 -0
  64. hypermind/mind/integration.py +190 -0
  65. hypermind/mind/mediator.py +74 -0
  66. hypermind/mind/memory_store.py +129 -0
  67. hypermind/mind/policy.py +307 -0
  68. hypermind/mind/records.py +260 -0
  69. hypermind/mind/roles.py +190 -0
  70. hypermind/mind/sybil_guard.py +41 -0
  71. hypermind/mind/world_model.py +166 -0
  72. hypermind/namespace.py +515 -0
  73. hypermind/namespace_policy.py +254 -0
  74. hypermind/observability/__init__.py +25 -0
  75. hypermind/observability/asgi.py +214 -0
  76. hypermind/observability/cost.py +277 -0
  77. hypermind/observability/otel_export.py +200 -0
  78. hypermind/observability/recorder.py +344 -0
  79. hypermind/observability/swarm_trace.py +438 -0
  80. hypermind/pin_policy.py +116 -0
  81. hypermind/py.typed +0 -0
  82. hypermind/responders/__init__.py +87 -0
  83. hypermind/responders/anthropic.py +159 -0
  84. hypermind/responders/base.py +162 -0
  85. hypermind/responders/openai.py +199 -0
  86. hypermind/responders/router.py +254 -0
  87. hypermind/responders/structured.py +287 -0
  88. hypermind/responders/tools.py +444 -0
  89. hypermind/revocation.py +270 -0
  90. hypermind/rule_ids.py +135 -0
  91. hypermind/schemas/encrypted_statement.cddl +28 -0
  92. hypermind/schemas/namespace_accept.cddl +20 -0
  93. hypermind/schemas/namespace_create.cddl +25 -0
  94. hypermind/schemas/namespace_founding_attest.cddl +30 -0
  95. hypermind/schemas/namespace_invite.cddl +22 -0
  96. hypermind/schemas/wire-v0.1.cddl +73 -0
  97. hypermind/simlab/__init__.py +15 -0
  98. hypermind/simlab/_persona_registry.py +93 -0
  99. hypermind/simlab/_scenario_impl.py +2778 -0
  100. hypermind/simlab/app.py +2538 -0
  101. hypermind/simlab/backends/__init__.py +27 -0
  102. hypermind/simlab/backends/anchor.py +88 -0
  103. hypermind/simlab/backends/local.py +32 -0
  104. hypermind/simlab/backends/server.py +79 -0
  105. hypermind/simlab/cli_command.py +108 -0
  106. hypermind/simlab/config.py +106 -0
  107. hypermind/simlab/deployment.py +26 -0
  108. hypermind/simlab/knowledge.py +716 -0
  109. hypermind/simlab/learning.py +295 -0
  110. hypermind/simlab/modes/__init__.py +115 -0
  111. hypermind/simlab/modes/_eval_real_llm.py +373 -0
  112. hypermind/simlab/modes/aar.py +611 -0
  113. hypermind/simlab/modes/backtest.py +610 -0
  114. hypermind/simlab/modes/binary_forecast.py +69 -0
  115. hypermind/simlab/modes/conformance.py +1869 -0
  116. hypermind/simlab/modes/evaluation.py +2271 -0
  117. hypermind/simlab/modes/governance.py +648 -0
  118. hypermind/simlab/modes/redteam.py +534 -0
  119. hypermind/simlab/modes/tabletop.py +797 -0
  120. hypermind/simlab/modes/tournament.py +448 -0
  121. hypermind/simlab/modes/whatif.py +523 -0
  122. hypermind/simlab/namespace.py +125 -0
  123. hypermind/simlab/personas.py +477 -0
  124. hypermind/simlab/prompt_generator.py +172 -0
  125. hypermind/simlab/question_sets/geopolitics_q20.json +122 -0
  126. hypermind/simlab/question_sets/governance_q5.json +67 -0
  127. hypermind/simlab/question_sets/msft_outlook_q10.json +62 -0
  128. hypermind/simlab/question_sets/whatif_q3.json +38 -0
  129. hypermind/simlab/registry.py +325 -0
  130. hypermind/simlab/runner.py +239 -0
  131. hypermind/simlab/scenario.py +147 -0
  132. hypermind/simlab/swarms.py +180 -0
  133. hypermind/simlab/tool_handlers/__init__.py +4 -0
  134. hypermind/simlab/tool_handlers/alpha_vantage.py +39 -0
  135. hypermind/simlab/tool_handlers/brave.py +46 -0
  136. hypermind/simlab/tool_handlers/newsapi.py +50 -0
  137. hypermind/simlab/tool_handlers/openweather.py +46 -0
  138. hypermind/simlab/tool_handlers/pinecone.py +52 -0
  139. hypermind/simlab/tool_handlers/qdrant.py +57 -0
  140. hypermind/simlab/tool_handlers/query_knowledge_base.py +21 -0
  141. hypermind/simlab/tool_handlers/relata_recall.py +61 -0
  142. hypermind/simlab/tool_handlers/retrieve_document.py +21 -0
  143. hypermind/simlab/tool_handlers/serper.py +46 -0
  144. hypermind/simlab/tool_handlers/tavily.py +53 -0
  145. hypermind/simlab/tool_handlers/weaviate.py +65 -0
  146. hypermind/simlab/tool_handlers/wikipedia.py +64 -0
  147. hypermind/simlab/tool_handlers/wolfram.py +48 -0
  148. hypermind/simlab/tool_handlers/yahoo_finance.py +49 -0
  149. hypermind/simlab/tool_registry.py +568 -0
  150. hypermind/simlab/topic_adapter.py +338 -0
  151. hypermind/simlab/topic_questions.py +259 -0
  152. hypermind/storage/__init__.py +69 -0
  153. hypermind/storage/backend.py +71 -0
  154. hypermind/storage/relata.py +245 -0
  155. hypermind/storage/sqlite.py +258 -0
  156. hypermind/sync.py +191 -0
  157. hypermind/tal.py +175 -0
  158. hypermind/tasks.py +334 -0
  159. hypermind/testing.py +16 -0
  160. hypermind/tools/__init__.py +32 -0
  161. hypermind/tools/registry.py +61 -0
  162. hypermind/transport/__init__.py +22 -0
  163. hypermind/transport/anti_entropy.py +708 -0
  164. hypermind/transport/bus.py +206 -0
  165. hypermind/transport/knows_delta.py +204 -0
  166. hypermind/transport/libp2p.py +298 -0
  167. hypermind/transport/placement.py +58 -0
  168. hypermind/transport/tcp.py +797 -0
  169. hypermind/transport/tls_profile.py +417 -0
  170. hypermind/types.py +59 -0
  171. hypermind/uncertainty.py +222 -0
  172. hypermind/wire.py +897 -0
  173. hypermind/workflow/__init__.py +72 -0
  174. hypermind/workflow/engine.py +655 -0
  175. hypermind/workflow/plan.py +182 -0
  176. hypermind/workflow/repair.py +203 -0
  177. hypermind-0.11.0.dist-info/METADATA +524 -0
  178. hypermind-0.11.0.dist-info/RECORD +181 -0
  179. hypermind-0.11.0.dist-info/WHEEL +4 -0
  180. hypermind-0.11.0.dist-info/entry_points.txt +2 -0
  181. hypermind-0.11.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,923 @@
1
+ """Capability tokens with caveat-chain attenuation.
2
+
3
+ Biscuit-shape semantics (monotonic narrowing) bound by COSE signature.
4
+ Closes Cycle-1 fix #5 and the MCP confused-deputy gap (§4.12.2).
5
+
6
+ Chain-of-trust binding (closes self-heal Cycle-1 Finding #1):
7
+ - Each token records `root_issuer` (the first issuer in the chain).
8
+ - Each attenuation records `parent_sig` (a hash of the parent's
9
+ canonical body+sig). The attenuated token's signed body therefore
10
+ proves "this attenuation was performed on a token whose body+sig
11
+ hash to `parent_sig`" — verifiers walk the chain back to a
12
+ recognised root issuer.
13
+ - `verify()` requires `expected_root_issuer` to be supplied so the
14
+ caller pins which root they trust; verifying without one only
15
+ checks self-consistency, NOT trust.
16
+
17
+ Each caveat narrows scope; first failing caveat raises AuthError with
18
+ `failed_caveat=<kind>` so operators can see exactly which restriction
19
+ fired. The caveat list and signing alg are inside the signed preimage
20
+ (A-ALG-BOUND), so JWT-`alg=none`-class substitution is impossible.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import hashlib
26
+ import hmac
27
+ import time
28
+ from dataclasses import dataclass, field
29
+ from datetime import UTC
30
+ from typing import TYPE_CHECKING, Any, Literal
31
+
32
+ import cbor2
33
+
34
+ from hypermind.crypto.signing import Keypair, verify_ed25519
35
+ from hypermind.errors import AuthError, ConformanceError
36
+ from hypermind.revocation import RevocationStore
37
+
38
+ if TYPE_CHECKING:
39
+ from hypermind.revocation import RevocationList
40
+
41
+ CaveatKind = Literal[
42
+ "topic",
43
+ "claim_type",
44
+ "rate",
45
+ "expiry",
46
+ "parent_kid",
47
+ "tool",
48
+ "ns",
49
+ "room",
50
+ "to",
51
+ ]
52
+
53
+ #: Closed set of valid caveat kinds (validated in `Caveat.__post_init__`).
54
+ #: `ns`, `room`, `to` were added at wire-version 2 per spec §25.1.
55
+ _VALID_CAVEAT_KINDS: frozenset[str] = frozenset(
56
+ {
57
+ "topic",
58
+ "claim_type",
59
+ "rate",
60
+ "expiry",
61
+ "parent_kid",
62
+ "tool",
63
+ "ns",
64
+ "room",
65
+ "to",
66
+ }
67
+ )
68
+
69
+
70
+ # -----------------------------------------------------------------------
71
+ # TokenBucket — runtime rate enforcement for "rate" caveats (P1-03)
72
+ # -----------------------------------------------------------------------
73
+
74
+
75
+ class TokenBucket:
76
+ """Leaky-bucket / token-bucket for rate-caveat enforcement.
77
+
78
+ Accepts rate strings in the forms used by ``Caveat.rate()``:
79
+ ``"5/min"``, ``"100/hour"``, ``"10/s"`` (also ``"sec"``, ``"second"``,
80
+ ``"minute"``, ``"hr"``).
81
+
82
+ Thread-safety: not thread-safe by itself; the caller (``_require_cap``)
83
+ holds a per-agent dict, which is already single-threaded inside the
84
+ async event loop.
85
+ """
86
+
87
+ def __init__(self, rate_str: str) -> None:
88
+ try:
89
+ per_min = _rate_per_min(rate_str)
90
+ except (ValueError, TypeError) as exc:
91
+ raise AuthError(
92
+ f"unparsable rate caveat {rate_str!r}: {exc}",
93
+ failed_caveat="rate",
94
+ ) from exc
95
+ # Capacity = max(1, per-second rate) burst tokens pre-filled at start.
96
+ self._refill_per_second: float = per_min / 60.0
97
+ self._capacity: float = max(1.0, per_min / 60.0)
98
+ self._tokens: float = self._capacity
99
+ self._last_refill: float = time.monotonic()
100
+
101
+ def consume(self) -> bool:
102
+ """Try to consume one token. Returns True if allowed, False if limited."""
103
+ now = time.monotonic()
104
+ elapsed = now - self._last_refill
105
+ self._last_refill = now
106
+ self._tokens = min(self._capacity, self._tokens + elapsed * self._refill_per_second)
107
+ if self._tokens >= 1.0:
108
+ self._tokens -= 1.0
109
+ return True
110
+ return False
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class Caveat:
115
+ """A single restriction in a caveat chain. Caveats are ordered;
116
+ each subsequent caveat may only narrow the scope of its predecessors.
117
+
118
+ Valid `kind` values are validated at construction (closes the silent
119
+ typo bypass — `Caveat("topik", ...)` previously parsed but never
120
+ enforced anything). Use the named constructors (`Caveat.topic(...)`,
121
+ `Caveat.rate(...)`, etc.) to avoid hand-typing the kind string.
122
+ """
123
+
124
+ kind: CaveatKind
125
+ value: Any
126
+
127
+ def __post_init__(self) -> None:
128
+ if self.kind not in _VALID_CAVEAT_KINDS:
129
+ raise ValueError(
130
+ f"unknown caveat kind {self.kind!r}.\n"
131
+ f" Valid kinds: {sorted(_VALID_CAVEAT_KINDS)}\n"
132
+ f" Hint: use Caveat.topic(...), Caveat.rate(...), "
133
+ f"Caveat.namespace(...), Caveat.room(...), Caveat.recipient(...) "
134
+ f"to avoid typos."
135
+ )
136
+
137
+ # ---- named constructors (typo-proof) -------------------------------
138
+
139
+ @classmethod
140
+ def topic(cls, glob: str) -> Caveat:
141
+ """Restrict to a topic glob, e.g. 'ai-safety/*' or 'finance/eval'."""
142
+ return cls(kind="topic", value=glob)
143
+
144
+ @classmethod
145
+ def claim_type(cls, name: str) -> Caveat:
146
+ """Restrict to a claim_type, e.g. 'eval_result' or 'threat_indicator'."""
147
+ return cls(kind="claim_type", value=name)
148
+
149
+ @classmethod
150
+ def rate(cls, spec: str) -> Caveat:
151
+ """Restrict invocation rate, e.g. '10/min', '60/sec', '1000/hr'."""
152
+ return cls(kind="rate", value=spec)
153
+
154
+ @classmethod
155
+ def expiry(cls, iso_timestamp: str) -> Caveat:
156
+ """Expire after the ISO-8601 timestamp (UTC, e.g. '2030-01-01T00:00Z')."""
157
+ return cls(kind="expiry", value=iso_timestamp)
158
+
159
+ @classmethod
160
+ def parent_kid(cls, kid: bytes | str) -> Caveat:
161
+ """Pin to a specific parent claim kid (32-byte hex or bytes)."""
162
+ return cls(kind="parent_kid", value=kid.hex() if isinstance(kid, bytes) else kid)
163
+
164
+ @classmethod
165
+ def tool(cls, name: str) -> Caveat:
166
+ """Restrict to a specific MCP tool, e.g. 'web_search' or 'shell'."""
167
+ return cls(kind="tool", value=name)
168
+
169
+ @classmethod
170
+ def namespace(cls, ns_id_hex: str) -> Caveat:
171
+ """Restrict to a HyperMind namespace (spec §25.1).
172
+
173
+ ``ns_id_hex`` is the lowercase hex of the namespace_id. At
174
+ wire-version 2 every CapToken root MUST carry one of these.
175
+ """
176
+ if not isinstance(ns_id_hex, str):
177
+ raise ValueError("namespace caveat value must be a hex string")
178
+ return cls(kind="ns", value=ns_id_hex.lower())
179
+
180
+ @classmethod
181
+ def room(cls, room_id: str) -> Caveat:
182
+ """Restrict to a TAL Room id (spec §25.1).
183
+
184
+ ``room_id`` is the hex of the Room id (a Room lives inside a
185
+ namespace; the `ns:` caveat carries the namespace anchor).
186
+ """
187
+ if not isinstance(room_id, str):
188
+ raise ValueError("room caveat value must be a hex string")
189
+ return cls(kind="room", value=room_id.lower())
190
+
191
+ @classmethod
192
+ def recipient(cls, kid: bytes | str) -> Caveat:
193
+ """Restrict to a specific recipient kid (spec §25.1).
194
+
195
+ Used for consult routing or encrypted-payload addressing —
196
+ only the named recipient kid may be the operation target.
197
+ """
198
+ if isinstance(kid, (bytes, bytearray)):
199
+ value = bytes(kid).hex()
200
+ elif isinstance(kid, str):
201
+ value = kid.lower()
202
+ else:
203
+ raise ValueError("recipient caveat value must be bytes or hex string")
204
+ return cls(kind="to", value=value)
205
+
206
+ def to_cbor(self) -> dict[str, Any]:
207
+ return {"kind": self.kind, "value": self.value}
208
+
209
+ @classmethod
210
+ def from_cbor(cls, m: dict[str, Any]) -> Caveat:
211
+ return cls(kind=m["kind"], value=m["value"])
212
+
213
+
214
+ @dataclass
215
+ class CapToken:
216
+ """Capability token with Biscuit-shape caveat-chain attenuation (spec §4.12).
217
+
218
+ A CapToken is minted by an issuer, optionally attenuated (narrowed) by
219
+ delegates, and presented at the point of use. The caveat list and signing
220
+ algorithm are bound inside the COSE signature (A-ALG-BOUND), making
221
+ JWT-``alg=none``-class substitution impossible. Only the current
222
+ ``subject`` may attenuate — any other signer is rejected.
223
+
224
+ Examples:
225
+ >>> from hypermind.capability import CapToken, Caveat
226
+ >>> token = CapToken.mint(
227
+ ... issuer_keypair=alice.keypair,
228
+ ... subject=bob.keypair.kid,
229
+ ... caveats=[Caveat.topic("ai-safety/*"), Caveat.rate("10/min")],
230
+ ... )
231
+ >>> delegated = token.attenuate(
232
+ ... new_subject=carol.keypair.kid,
233
+ ... extra_caveats=[Caveat.expiry("2030-01-01T00:00Z")],
234
+ ... signing_keypair=bob.keypair,
235
+ ... )
236
+ >>> delegated.verify(action="publish_claim", topic="ai-safety/eval")
237
+ """
238
+
239
+ issuer: bytes
240
+ subject: bytes
241
+ caveats: list[Caveat]
242
+ sig: bytes = b""
243
+ issued_at_ms: int = field(default_factory=lambda: int(time.time() * 1000))
244
+ root_issuer: bytes = b""
245
+ parent_sig: bytes = b"" # hash of parent's _canonical_body()+sig; b"" for root
246
+
247
+ # Private mint-namespace marker for v0/v1 grandfathering (spec §25.3).
248
+ # Set at mint() time; never serialised. Verifiers receiving a v1 token
249
+ # in a different namespace MUST reject it (BlockReason CASCADE-L5-CROSS-NS-V1).
250
+ # Prefixed with `_` so dataclass equality / serialisation are unaffected.
251
+ _mint_namespace: bytes | None = field(default=None, repr=False, compare=False)
252
+
253
+ def __post_init__(self) -> None:
254
+ if not self.root_issuer:
255
+ # A root token's own issuer IS the root.
256
+ self.root_issuer = self.issuer
257
+
258
+ @classmethod
259
+ def mint(
260
+ cls,
261
+ *,
262
+ issuer_keypair: Keypair,
263
+ subject: bytes,
264
+ caveats: list[Caveat] | None = None,
265
+ wire_version: int = 1,
266
+ namespace_id: bytes | None = None,
267
+ ) -> CapToken:
268
+ """Mint a new ROOT capability token. Use `attenuate()` to delegate.
269
+
270
+ Spec §25.2: at ``wire_version >= 2``, every CapToken root MUST carry
271
+ an explicit ``ns:<namespace_id_hex>`` caveat. ``mint`` will raise
272
+ ``ValueError`` if neither the caveat nor a ``namespace_id`` argument
273
+ is supplied (the argument is auto-converted to an ``ns`` caveat
274
+ when present).
275
+
276
+ For ``wire_version <= 1`` (legacy / grandfathered), the optional
277
+ ``namespace_id`` is recorded as the token's mint namespace
278
+ (``_mint_namespace``) so verifiers can enforce §25.3
279
+ cross-namespace rejection without requiring the caveat itself.
280
+ """
281
+ cavs = list(caveats or [])
282
+ existing_ns = next((c for c in cavs if c.kind == "ns"), None)
283
+
284
+ if wire_version >= 2:
285
+ if existing_ns is None:
286
+ if namespace_id is None:
287
+ raise ValueError(
288
+ "wire_version>=2 CapTokens MUST carry an `ns` caveat "
289
+ "(spec §25.2). Pass namespace_id=... or include "
290
+ "Caveat.namespace(...) in caveats."
291
+ )
292
+ cavs.insert(0, Caveat.namespace(namespace_id.hex()))
293
+ existing_ns = cavs[0]
294
+ elif namespace_id is not None and existing_ns.value != namespace_id.hex():
295
+ raise ValueError(
296
+ f"namespace_id={namespace_id.hex()!r} disagrees with "
297
+ f"explicit ns caveat {existing_ns.value!r}"
298
+ )
299
+
300
+ token = cls(
301
+ issuer=issuer_keypair.kid,
302
+ subject=subject,
303
+ caveats=cavs,
304
+ root_issuer=issuer_keypair.kid,
305
+ parent_sig=b"",
306
+ )
307
+ # Record mint namespace for grandfathering (§25.3).
308
+ if existing_ns is not None:
309
+ try:
310
+ token._mint_namespace = bytes.fromhex(str(existing_ns.value))
311
+ except ValueError:
312
+ token._mint_namespace = None
313
+ elif namespace_id is not None:
314
+ token._mint_namespace = bytes(namespace_id)
315
+ token.sig = issuer_keypair.sign(token._canonical_body())
316
+ return token
317
+
318
+ def attenuate(self, *new_caveats: Caveat, signing_keypair: Keypair) -> CapToken:
319
+ """Re-sign with additional caveats binding back to this parent.
320
+
321
+ The signing principal MUST be the current `subject` (the holder
322
+ delegating onward). Every new caveat — whether the same kind or
323
+ a brand-new kind — must be a strict narrowing of the existing
324
+ chain; new caveats CANNOT widen scope by omitting an existing
325
+ kind, and a new kind not present in the parent is unrestricted
326
+ UNLESS combined with an explicit narrowing of the same kind.
327
+
328
+ The new token carries `parent_sig` = hash(parent body || parent
329
+ sig) so chain-walk verification can confirm the parent was
330
+ intact at delegation time.
331
+ """
332
+ if signing_keypair.kid != self.subject:
333
+ raise AuthError(
334
+ "only the current subject may attenuate a capability",
335
+ failed_caveat="subject",
336
+ )
337
+
338
+ for c in new_caveats:
339
+ self._assert_narrows(c)
340
+
341
+ merged_caveats = list(self.caveats) + list(new_caveats)
342
+ parent_fp = _fingerprint(self._canonical_body() + self.sig)
343
+ token = CapToken(
344
+ issuer=signing_keypair.kid,
345
+ subject=self.subject,
346
+ caveats=merged_caveats,
347
+ root_issuer=self.root_issuer,
348
+ parent_sig=parent_fp,
349
+ )
350
+ # Inherit mint-namespace marker from parent (grandfathering §25.3).
351
+ token._mint_namespace = self._mint_namespace
352
+ token.sig = signing_keypair.sign(token._canonical_body())
353
+ return token
354
+
355
+ def _assert_narrows(self, new: Caveat) -> None:
356
+ """A caveat must be at least as restrictive as every same-kind
357
+ caveat already in the chain. A new caveat of a previously-unseen
358
+ kind is allowed (it adds a fresh restriction, never widens)."""
359
+ for existing in self.caveats:
360
+ if existing.kind != new.kind:
361
+ continue
362
+ if existing.kind == "rate":
363
+ if _rate_per_min(new.value) > _rate_per_min(existing.value):
364
+ raise AuthError(
365
+ f"rate caveat must narrow: {new.value} > {existing.value}",
366
+ failed_caveat="rate",
367
+ )
368
+ elif existing.kind == "expiry":
369
+ if _parse_iso_to_ms(str(new.value)) > _parse_iso_to_ms(str(existing.value)):
370
+ raise AuthError(
371
+ f"expiry caveat must narrow: {new.value} > {existing.value}",
372
+ failed_caveat="expiry",
373
+ )
374
+ elif existing.kind in ("topic", "claim_type", "tool"):
375
+ if not _is_topic_subset(new.value, existing.value):
376
+ raise AuthError(
377
+ f"{existing.kind} caveat must narrow",
378
+ failed_caveat=existing.kind,
379
+ )
380
+ elif existing.kind == "parent_kid":
381
+ if str(new.value) != str(existing.value):
382
+ raise AuthError(
383
+ "parent_kid caveat is exact-match; cannot change",
384
+ failed_caveat="parent_kid",
385
+ )
386
+ elif existing.kind in ("ns", "room", "to"):
387
+ if str(new.value) != str(existing.value):
388
+ raise AuthError(
389
+ f"{existing.kind} caveat is exact-match; cannot change",
390
+ failed_caveat=existing.kind,
391
+ )
392
+
393
+ def _ns_caveat_value(self) -> str | None:
394
+ """Return the `ns:` caveat value (lowercase hex) or None."""
395
+ for c in self.caveats:
396
+ if c.kind == "ns":
397
+ return str(c.value).lower()
398
+ return None
399
+
400
+ def _check_namespace_gate(self, wire_version: int, current_namespace_id: bytes | None) -> bool:
401
+ """Spec §25.2 / §25.3 — namespace gating for verify_chain.
402
+
403
+ - wire_version >= 2: token MUST carry an `ns` caveat. If
404
+ ``current_namespace_id`` is supplied, the caveat value MUST
405
+ match it (lowercase hex). Mismatch ⇒ False.
406
+ - wire_version <= 1: legacy / grandfathered. If
407
+ ``current_namespace_id`` is supplied AND the token has a
408
+ recorded mint namespace (or explicit `ns` caveat), they must
409
+ match — otherwise reject (spec §25.3, BlockReason
410
+ ``CASCADE-L5-CROSS-NS-V1``).
411
+ """
412
+ ns_caveat = self._ns_caveat_value()
413
+ if wire_version >= 2:
414
+ if ns_caveat is None:
415
+ return False
416
+ if current_namespace_id is not None:
417
+ if ns_caveat != current_namespace_id.hex().lower():
418
+ return False
419
+ return True
420
+ # wire_version <= 1
421
+ if current_namespace_id is None:
422
+ return True
423
+ if ns_caveat is not None:
424
+ return ns_caveat == current_namespace_id.hex().lower()
425
+ if self._mint_namespace is not None:
426
+ return bytes(self._mint_namespace) == bytes(current_namespace_id)
427
+ # No mint-namespace recorded and no caveat: cannot prove origin.
428
+ # Caller has pinned a namespace, so refuse conservatively.
429
+ return False
430
+
431
+ def _canonical_body(self) -> bytes:
432
+ """The canonical signed preimage. Includes root_issuer +
433
+ parent_sig so substitution attacks cannot forge a fake chain."""
434
+ return cbor2.dumps(
435
+ {
436
+ "issuer": self.issuer,
437
+ "subject": self.subject,
438
+ "caveats": [c.to_cbor() for c in self.caveats],
439
+ "issued_at_ms": self.issued_at_ms,
440
+ "root_issuer": self.root_issuer,
441
+ "parent_sig": self.parent_sig,
442
+ },
443
+ canonical=True,
444
+ )
445
+
446
+ def to_bytes(self) -> bytes:
447
+ """Serialize as deterministic CBOR (RFC 8949 §4.2)."""
448
+ return cbor2.dumps(
449
+ {
450
+ "issuer": self.issuer,
451
+ "subject": self.subject,
452
+ "caveats": [c.to_cbor() for c in self.caveats],
453
+ "issued_at_ms": self.issued_at_ms,
454
+ "root_issuer": self.root_issuer,
455
+ "parent_sig": self.parent_sig,
456
+ "sig": self.sig,
457
+ },
458
+ canonical=True,
459
+ )
460
+
461
+ @classmethod
462
+ def from_bytes(cls, raw: bytes) -> CapToken:
463
+ """Parse a CapToken from canonical CBOR.
464
+
465
+ Enforces the same A-ALG-BOUND-style canonical-encoding gate as
466
+ `SignedStatement.from_bytes()`: parse, re-encode under
467
+ deterministic CBOR, and require byte-equality with the wire
468
+ bytes. Rejects non-canonical inputs (reordered map keys,
469
+ indefinite-length strings, alternative integer encodings) at
470
+ parse time so a single logical token cannot have two distinct
471
+ wire encodings (and thus two distinct fingerprints).
472
+ """
473
+ try:
474
+ m = cbor2.loads(raw)
475
+ except cbor2.CBORDecodeError as e:
476
+ raise ConformanceError(f"capability token is not valid CBOR: {e}") from e
477
+ if not isinstance(m, dict):
478
+ raise ConformanceError("capability token must be a CBOR map")
479
+ if cbor2.dumps(m, canonical=True) != raw:
480
+ raise ConformanceError("capability token is not canonical CBOR (RFC 8949 §4.2)")
481
+ try:
482
+ token = cls(
483
+ issuer=bytes(m["issuer"]),
484
+ subject=bytes(m["subject"]),
485
+ caveats=[Caveat.from_cbor(c) for c in m["caveats"]],
486
+ sig=bytes(m["sig"]),
487
+ issued_at_ms=int(m["issued_at_ms"]),
488
+ root_issuer=bytes(m["root_issuer"]),
489
+ parent_sig=bytes(m["parent_sig"]),
490
+ )
491
+ except (KeyError, TypeError) as e:
492
+ raise ConformanceError(f"capability token missing/invalid field: {e}") from e
493
+ return token
494
+
495
+ def verify(self, *, expected_root_issuer: bytes | None = None) -> bool:
496
+ """Verify the issuer's signature.
497
+
498
+ If `expected_root_issuer` is supplied, also verify that
499
+ `self.root_issuer == expected_root_issuer`. Without that pin,
500
+ verification only proves self-consistency, NOT that the chain
501
+ traces back to a trusted authority — callers MUST supply the
502
+ expected root in any security-relevant context.
503
+ """
504
+ if expected_root_issuer is not None and self.root_issuer != expected_root_issuer:
505
+ return False
506
+ return verify_ed25519(self.issuer, self.sig, self._canonical_body())
507
+
508
+ def verify_chain(
509
+ self,
510
+ parent: CapToken | None,
511
+ *,
512
+ revocation_list: RevocationList | None = None,
513
+ wire_version: int = 1,
514
+ current_namespace_id: bytes | None = None,
515
+ ) -> bool:
516
+ """Verify the leaf's own signature AND the chain link to `parent`.
517
+
518
+ Closes Cycle-2 Finding #3: earlier versions skipped the leaf's
519
+ own signature, allowing an unsigned token to pass `verify_chain`
520
+ if its `parent_sig` field happened to match. Now the leaf's
521
+ signature is checked first; only then is the parent link verified.
522
+
523
+ - Root token (`parent=None`): requires `parent_sig == b""` and
524
+ `issuer == root_issuer`.
525
+ - Delegated token: requires the parent's signature is valid,
526
+ `parent.subject == self.issuer`, root_issuer matches,
527
+ `self.parent_sig` == hash(parent body || parent sig), and
528
+ every same-kind caveat in self is a strict narrowing of the
529
+ parent's same-kind caveat (defence-in-depth: even if a
530
+ forged token bypassed `attenuate()`'s narrowing checks, a
531
+ chain walk catches scope widening).
532
+
533
+ P2-06: if ``revocation_list`` is supplied, any token whose issuer
534
+ kid appears in the list (regardless of HLC — epoch check) raises
535
+ :class:`AuthError` with ``rule_id="HM-REV-001"``. The check is
536
+ applied to both ``self`` and ``parent`` so a revoked delegator
537
+ cannot ride a still-valid leaf.
538
+ """
539
+ if not self.verify():
540
+ return False
541
+ # Spec §25.2 / §25.3: namespace gating.
542
+ if not self._check_namespace_gate(wire_version, current_namespace_id):
543
+ return False
544
+ if parent is None:
545
+ if revocation_list is not None:
546
+ _check_revocation_list(self.issuer, revocation_list)
547
+ return hmac.compare_digest(self.parent_sig, b"") and hmac.compare_digest(
548
+ self.issuer, self.root_issuer
549
+ )
550
+ if not parent.verify():
551
+ return False
552
+ if revocation_list is not None:
553
+ _check_revocation_list(self.issuer, revocation_list)
554
+ _check_revocation_list(parent.issuer, revocation_list)
555
+ if not hmac.compare_digest(parent.subject, self.issuer):
556
+ return False
557
+ if not hmac.compare_digest(parent.root_issuer, self.root_issuer):
558
+ return False
559
+ if not hmac.compare_digest(
560
+ self.parent_sig, _fingerprint(parent._canonical_body() + parent.sig)
561
+ ):
562
+ return False
563
+ return _caveats_narrow(parent.caveats, self.caveats)
564
+
565
+ def verify_chain_full(
566
+ self,
567
+ chain: list[CapToken] | None = None,
568
+ *,
569
+ expected_root_issuer: bytes | None = None,
570
+ revocation_store: RevocationStore | None = None,
571
+ hlc: tuple[int, int] | None = None,
572
+ wire_version: int = 1,
573
+ current_namespace_id: bytes | None = None,
574
+ ) -> bool:
575
+ """Walk the full chain root → … → self.
576
+
577
+ `chain` is the ordered list of ancestor tokens, root first, NOT
578
+ including `self`. For a root token, pass `[]` or `None`. Returns
579
+ True only if every link verifies (signature, parent_sig binding,
580
+ root_issuer consistency, and monotonic narrowing of caveats).
581
+
582
+ If `expected_root_issuer` is supplied, also checks that the
583
+ root's issuer kid matches — pinning the chain to a trusted root.
584
+
585
+ If `revocation_store` is supplied, every token's `issuer` kid is
586
+ checked against it at the supplied `hlc` (defaults to (0, 0) =
587
+ "now"; a more accurate value pins historical-claim immutability
588
+ — statements signed strictly before the revocation point remain
589
+ valid). Any revoked kid raises :class:`AuthError` with
590
+ ``failed_caveat='revoked'`` (rule_id ``HM-KEY-REVOKED-001``).
591
+ """
592
+ ancestors = list(chain or [])
593
+
594
+ def _check_revoked(token: CapToken) -> None:
595
+ if revocation_store is None:
596
+ return
597
+ entry = revocation_store.is_revoked(token.issuer, hlc or (0, 0))
598
+ if entry is not None:
599
+ raise AuthError(
600
+ f"capability token issuer kid {token.issuer.hex()[:8]}… "
601
+ f"has been revoked (reason={entry.reason}, "
602
+ f"rule_id=HM-KEY-REVOKED-001)",
603
+ failed_caveat="revoked",
604
+ )
605
+
606
+ if not ancestors:
607
+ if not self.verify_chain(
608
+ None,
609
+ wire_version=wire_version,
610
+ current_namespace_id=current_namespace_id,
611
+ ):
612
+ return False
613
+ if expected_root_issuer is not None and not hmac.compare_digest(
614
+ self.root_issuer, expected_root_issuer
615
+ ):
616
+ return False
617
+ _check_revoked(self)
618
+ return True
619
+
620
+ root = ancestors[0]
621
+ if not root.verify_chain(
622
+ None,
623
+ wire_version=wire_version,
624
+ current_namespace_id=current_namespace_id,
625
+ ):
626
+ return False
627
+ if expected_root_issuer is not None and not hmac.compare_digest(
628
+ root.root_issuer, expected_root_issuer
629
+ ):
630
+ return False
631
+ _check_revoked(root)
632
+
633
+ prev = root
634
+ for token in ancestors[1:]:
635
+ if not token.verify_chain(
636
+ prev,
637
+ wire_version=wire_version,
638
+ current_namespace_id=current_namespace_id,
639
+ ):
640
+ return False
641
+ _check_revoked(token)
642
+ prev = token
643
+ if not self.verify_chain(
644
+ prev,
645
+ wire_version=wire_version,
646
+ current_namespace_id=current_namespace_id,
647
+ ):
648
+ return False
649
+ _check_revoked(self)
650
+ return True
651
+
652
+ def authorize(
653
+ self,
654
+ *,
655
+ action: str,
656
+ topic: str | None = None,
657
+ claim_type: str | None = None,
658
+ tool: str | None = None,
659
+ parent_kid: bytes | None = None,
660
+ expected_root_issuer: bytes | None = None,
661
+ revocation_store: RevocationStore | None = None,
662
+ hlc: tuple[int, int] | None = None,
663
+ require_chain: bool = True,
664
+ room: str | None = None,
665
+ recipient: bytes | str | None = None,
666
+ namespace_id: bytes | None = None,
667
+ wire_version: int = 1,
668
+ rate_store: Any | None = None,
669
+ ) -> None:
670
+ """Check that this token authorizes the requested action.
671
+
672
+ Raises AuthError(failed_caveat=...) on first failing caveat.
673
+ Caveats are evaluated in order; the first failure is reported,
674
+ so operators can pinpoint which restriction fired without
675
+ guessing the chain semantics.
676
+
677
+ For chain-of-trust enforcement, supply `expected_root_issuer`
678
+ — the kid of the principal you trust at the root of the chain.
679
+
680
+ Root tokens (parent_sig == b"") are verified via verify_chain(None)
681
+ so the issuer == root_issuer invariant is always checked.
682
+
683
+ Delegated tokens (parent_sig != b""):
684
+ - When ``require_chain=True`` (the default): raises AuthError if the
685
+ caller has not first called ``verify_chain_full()`` on the ancestor
686
+ chain. Pass ``require_chain=True`` only after that check passes.
687
+ This enforces that the full chain (root → … → self) has been verified.
688
+ - When ``require_chain=False`` (opt-out, legacy callers only): falls
689
+ back to checking the leaf signature only. Does NOT verify the parent
690
+ chain — a forged delegated token with a valid leaf sig will pass.
691
+ Callers using this path should migrate to ``verify_chain_full()``.
692
+ """
693
+ if topic is not None and _normalize_topic(topic) is None:
694
+ raise AuthError("topic contains path-traversal components", failed_caveat="topic")
695
+
696
+ if self.parent_sig == b"":
697
+ if not self.verify_chain(
698
+ None,
699
+ wire_version=wire_version,
700
+ current_namespace_id=namespace_id,
701
+ ):
702
+ raise AuthError("capability chain invalid", failed_caveat="signature")
703
+ if expected_root_issuer is not None and self.root_issuer != expected_root_issuer:
704
+ raise AuthError("root_issuer mismatch", failed_caveat="signature")
705
+ else:
706
+ if require_chain:
707
+ raise AuthError(
708
+ "delegated CapToken passed to authorize() without prior chain verification; "
709
+ "call verify_chain_full() on the full ancestor chain first, then pass "
710
+ "require_chain=True only after that check passes",
711
+ failed_caveat="signature",
712
+ )
713
+ # DEPRECATED: leaf-only verification for delegated tokens.
714
+ # Does not verify the parent chain — use verify_chain_full() instead.
715
+ if not self.verify(expected_root_issuer=expected_root_issuer):
716
+ raise AuthError("capability signature invalid", failed_caveat="signature")
717
+
718
+ # Revocation check (HM-KEY-REVOKED-001). The leaf token's issuer
719
+ # is the immediate signer; if its kid is revoked at `hlc`, refuse
720
+ # the action. Callers wanting full-chain revocation should call
721
+ # ``verify_chain_full(..., revocation_store=, hlc=)`` explicitly.
722
+ if revocation_store is not None:
723
+ entry = revocation_store.is_revoked(self.issuer, hlc or (0, 0))
724
+ if entry is not None:
725
+ raise AuthError(
726
+ f"capability token issuer kid {self.issuer.hex()[:8]}… "
727
+ f"has been revoked (reason={entry.reason}, "
728
+ f"rule_id=HM-KEY-REVOKED-001)",
729
+ failed_caveat="revoked",
730
+ )
731
+ # Also check the root, so a revoked-at-root delegate cannot
732
+ # ride on a still-active leaf signature.
733
+ if self.root_issuer != self.issuer:
734
+ root_entry = revocation_store.is_revoked(self.root_issuer, hlc or (0, 0))
735
+ if root_entry is not None:
736
+ raise AuthError(
737
+ f"capability root_issuer kid {self.root_issuer.hex()[:8]}… "
738
+ f"has been revoked (reason={root_entry.reason}, "
739
+ f"rule_id=HM-KEY-REVOKED-001)",
740
+ failed_caveat="revoked",
741
+ )
742
+
743
+ now_ms = int(time.time() * 1000)
744
+ for cav in self.caveats:
745
+ if cav.kind == "expiry":
746
+ expiry_ms = _parse_iso_to_ms(str(cav.value))
747
+ if now_ms > expiry_ms:
748
+ raise AuthError(f"capability expired at {cav.value}", failed_caveat="expiry")
749
+ elif cav.kind == "topic" and topic is not None:
750
+ if not _is_topic_subset(topic, str(cav.value)):
751
+ raise AuthError(
752
+ f"topic {topic!r} not in caveat {cav.value!r}",
753
+ failed_caveat="topic",
754
+ )
755
+ elif cav.kind == "claim_type" and claim_type is not None:
756
+ if not _is_topic_subset(claim_type, str(cav.value)):
757
+ raise AuthError(
758
+ f"claim_type {claim_type!r} not in caveat {cav.value!r}",
759
+ failed_caveat="claim_type",
760
+ )
761
+ elif cav.kind == "tool" and tool is not None:
762
+ if not _is_topic_subset(tool, str(cav.value)):
763
+ raise AuthError(
764
+ f"tool {tool!r} not in caveat {cav.value!r}",
765
+ failed_caveat="tool",
766
+ )
767
+ elif cav.kind == "parent_kid" and parent_kid is not None:
768
+ expected = bytes.fromhex(str(cav.value))
769
+ if parent_kid != expected:
770
+ raise AuthError(
771
+ "parent_kid does not match caveat",
772
+ failed_caveat="parent_kid",
773
+ )
774
+ elif cav.kind == "rate":
775
+ _rate_per_min(cav.value) # validate format
776
+ if rate_store is not None:
777
+ # Delegate enforcement to the caller-supplied store.
778
+ limit = _rate_per_min(cav.value)
779
+ token_key = self.sig[:16].hex() + ":" + action
780
+ if hasattr(rate_store, "check") and not rate_store.check(token_key, limit):
781
+ raise AuthError(
782
+ f"rate limit {cav.value} exceeded for action {action!r}",
783
+ failed_caveat="rate",
784
+ )
785
+ # When rate_store is None, format validation passes but enforcement
786
+ # is the caller's responsibility (e.g. HyperMindAgent uses TokenBucket
787
+ # after authorize()). This is intentional: authorize() is stateless.
788
+ elif cav.kind == "ns" and namespace_id is not None:
789
+ if str(cav.value).lower() != namespace_id.hex().lower():
790
+ raise AuthError(
791
+ f"namespace {namespace_id.hex()!r} not in caveat {cav.value!r}",
792
+ failed_caveat="ns",
793
+ )
794
+ elif cav.kind == "room" and room is not None:
795
+ if str(cav.value).lower() != str(room).lower():
796
+ raise AuthError(
797
+ f"room {room!r} not in caveat {cav.value!r}",
798
+ failed_caveat="room",
799
+ )
800
+ elif cav.kind == "to" and recipient is not None:
801
+ expected_to = (
802
+ bytes(recipient).hex()
803
+ if isinstance(recipient, (bytes, bytearray))
804
+ else str(recipient).lower()
805
+ )
806
+ if str(cav.value).lower() != expected_to:
807
+ raise AuthError(
808
+ f"recipient {expected_to!r} not in caveat {cav.value!r}",
809
+ failed_caveat="to",
810
+ )
811
+
812
+ _ = action # rate-limit hookpoint; runtime tracks usage
813
+
814
+
815
+ def _check_revocation_list(kid: bytes, revocation_list: RevocationList) -> None:
816
+ """P2-06: Raise AuthError if ``kid`` appears in the revocation list.
817
+
818
+ This is an epoch check: the presence of the kid in the list at all
819
+ is sufficient for rejection regardless of HLC (conservative policy —
820
+ once revoked, always revoked for capability chain verification).
821
+ Rule id: ``HM-REV-001``.
822
+ """
823
+ for entry in revocation_list.entries:
824
+ if bytes(entry.kid) == bytes(kid):
825
+ raise AuthError(
826
+ f"capability token issuer kid {kid.hex()[:8]}… "
827
+ f"has been revoked (reason={entry.reason}, rule_id=HM-REV-001)",
828
+ failed_caveat="revoked",
829
+ )
830
+
831
+
832
+ def _fingerprint(b: bytes) -> bytes:
833
+ """SHA-256 over canonical bytes — used to bind parent → child."""
834
+ return hashlib.sha256(b).digest()
835
+
836
+
837
+ def _caveats_narrow(parent_caveats: list[Caveat], child_caveats: list[Caveat]) -> bool:
838
+ """Child's caveat list must extend parent's prefix and only narrow.
839
+
840
+ `attenuate()` produces `child.caveats = parent.caveats + new_caveats`,
841
+ so a well-formed child has the parent's caveats as an exact prefix.
842
+ Each appended caveat must be a strict narrowing of any same-kind
843
+ caveat earlier in the chain (or introduce a fresh restriction kind).
844
+ """
845
+ if len(child_caveats) < len(parent_caveats):
846
+ return False
847
+ for i, p in enumerate(parent_caveats):
848
+ c = child_caveats[i]
849
+ if c.kind != p.kind or c.value != p.value:
850
+ return False
851
+ for added in child_caveats[len(parent_caveats) :]:
852
+ for existing in parent_caveats:
853
+ if existing.kind != added.kind:
854
+ continue
855
+ try:
856
+ if existing.kind == "rate":
857
+ if _rate_per_min(added.value) > _rate_per_min(existing.value):
858
+ return False
859
+ elif existing.kind == "expiry":
860
+ if _parse_iso_to_ms(str(added.value)) > _parse_iso_to_ms(str(existing.value)):
861
+ return False
862
+ elif existing.kind in ("topic", "claim_type", "tool"):
863
+ if not _is_topic_subset(str(added.value), str(existing.value)):
864
+ return False
865
+ elif existing.kind == "parent_kid":
866
+ if str(added.value) != str(existing.value):
867
+ return False
868
+ elif existing.kind in ("ns", "room", "to"):
869
+ if str(added.value) != str(existing.value):
870
+ return False
871
+ except (AuthError, ValueError):
872
+ return False
873
+ return True
874
+
875
+
876
+ def _rate_per_min(spec: Any) -> float:
877
+ """Parse 'N/min' or 'N/sec' into per-minute float."""
878
+ s = str(spec)
879
+ n_str, _, unit = s.partition("/")
880
+ n = float(n_str)
881
+ unit = unit.strip()
882
+ if unit in ("min", "minute", ""):
883
+ return n
884
+ if unit in ("sec", "second"):
885
+ return n * 60
886
+ if unit in ("hr", "hour"):
887
+ return n / 60
888
+ raise AuthError(f"unparsable rate {spec!r}", failed_caveat="rate")
889
+
890
+
891
+ def _normalize_topic(s: str) -> str | None:
892
+ """Return None if the topic contains path-traversal components."""
893
+ parts = s.split("/")
894
+ if any(p in (".", "..") for p in parts):
895
+ return None
896
+ return s
897
+
898
+
899
+ def _is_topic_subset(child: str, parent: str) -> bool:
900
+ """Glob-style subset: parent='ai-safety/*' covers child='ai-safety/eval'."""
901
+ if _normalize_topic(child) is None:
902
+ return False
903
+ parent_check = parent.rstrip("*").rstrip("/") if parent.endswith("/*") else parent
904
+ if _normalize_topic(parent_check) is None:
905
+ return False
906
+ if parent == child:
907
+ return True
908
+ if parent.endswith("/*"):
909
+ return child.startswith(parent[:-1])
910
+ if parent == "*":
911
+ return True
912
+ return False
913
+
914
+
915
+ def _parse_iso_to_ms(s: str) -> int:
916
+ from datetime import datetime
917
+
918
+ if s.endswith("Z"):
919
+ s = s[:-1] + "+00:00"
920
+ dt = datetime.fromisoformat(s)
921
+ if dt.tzinfo is None:
922
+ dt = dt.replace(tzinfo=UTC)
923
+ return int(dt.timestamp() * 1000)