agentfox 0.3.1__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 (171) hide show
  1. agentfox/__init__.py +45 -0
  2. agentfox/agent_loop.py +222 -0
  3. agentfox/agent_messaging.py +90 -0
  4. agentfox/answerability.py +605 -0
  5. agentfox/arbitration.py +309 -0
  6. agentfox/attribution.py +538 -0
  7. agentfox/audit/__init__.py +47 -0
  8. agentfox/audit/chain.py +420 -0
  9. agentfox/audit/evidence.py +560 -0
  10. agentfox/audit/otel.py +160 -0
  11. agentfox/audit/siem.py +233 -0
  12. agentfox/audit/trace.py +337 -0
  13. agentfox/autoguard.py +1261 -0
  14. agentfox/availability.py +702 -0
  15. agentfox/business/__init__.py +43 -0
  16. agentfox/business/catalogue.py +583 -0
  17. agentfox/business/compile.py +1010 -0
  18. agentfox/business/graph.py +314 -0
  19. agentfox/business/ladder.py +396 -0
  20. agentfox/business/store.py +159 -0
  21. agentfox/cli/__init__.py +5 -0
  22. agentfox/cli/_style.py +40 -0
  23. agentfox/cli/auth_cli.py +222 -0
  24. agentfox/cli/business_cli.py +403 -0
  25. agentfox/cli/capability_cli.py +475 -0
  26. agentfox/cli/controls_cli.py +516 -0
  27. agentfox/cli/demo.py +662 -0
  28. agentfox/cli/main.py +2065 -0
  29. agentfox/cli/mcp_cli.py +39 -0
  30. agentfox/cli/onboarding.py +682 -0
  31. agentfox/cli/quickscan.py +187 -0
  32. agentfox/cli/submit.py +120 -0
  33. agentfox/commitments.py +496 -0
  34. agentfox/compliance/__init__.py +43 -0
  35. agentfox/compliance/catalog.py +250 -0
  36. agentfox/compliance/risk.py +380 -0
  37. agentfox/compliance/status.py +524 -0
  38. agentfox/compliance_data/controls.yaml +944 -0
  39. agentfox/compliance_data/obligations.yaml +91 -0
  40. agentfox/config.py +502 -0
  41. agentfox/context_integrity.py +609 -0
  42. agentfox/control_flow.py +164 -0
  43. agentfox/crypto.py +45 -0
  44. agentfox/data_access.py +384 -0
  45. agentfox/db.py +233 -0
  46. agentfox/discovery.py +847 -0
  47. agentfox/discovery_openapi.py +113 -0
  48. agentfox/effects.py +461 -0
  49. agentfox/enforcement.py +3161 -0
  50. agentfox/entitlement.py +464 -0
  51. agentfox/escalation.py +826 -0
  52. agentfox/evaluation/__init__.py +98 -0
  53. agentfox/evaluation/adapters.py +189 -0
  54. agentfox/evaluation/adaptive.py +1098 -0
  55. agentfox/evaluation/drift.py +303 -0
  56. agentfox/evaluation/gating.py +309 -0
  57. agentfox/evaluation/model_groundedness.py +127 -0
  58. agentfox/evaluation/ragas_adapter.py +241 -0
  59. agentfox/evaluation/redteam.py +1280 -0
  60. agentfox/evaluation/runner.py +366 -0
  61. agentfox/evaluation/scorers.py +350 -0
  62. agentfox/evaluation/silent_failure.py +438 -0
  63. agentfox/finding.py +40 -0
  64. agentfox/findings.py +377 -0
  65. agentfox/gateway/__init__.py +5 -0
  66. agentfox/gateway/app.py +555 -0
  67. agentfox/gateway/auth.py +307 -0
  68. agentfox/gateway/deps.py +126 -0
  69. agentfox/gateway/playground_sessions.py +414 -0
  70. agentfox/gateway/routes/__init__.py +1 -0
  71. agentfox/gateway/routes/answerability.py +165 -0
  72. agentfox/gateway/routes/discovery.py +99 -0
  73. agentfox/gateway/routes/entitlement.py +213 -0
  74. agentfox/gateway/routes/escalation.py +326 -0
  75. agentfox/gateway/routes/evaluation.py +798 -0
  76. agentfox/gateway/routes/governance.py +675 -0
  77. agentfox/gateway/routes/inline.py +964 -0
  78. agentfox/gateway/routes/integrations.py +608 -0
  79. agentfox/gateway/routes/jobs.py +145 -0
  80. agentfox/gateway/routes/memory.py +101 -0
  81. agentfox/gateway/routes/messaging.py +82 -0
  82. agentfox/gateway/routes/onboarding.py +293 -0
  83. agentfox/gateway/routes/playground.py +272 -0
  84. agentfox/gateway/routes/playground_deps.py +34 -0
  85. agentfox/gateway/routes/policy.py +512 -0
  86. agentfox/gateway/routes/proposals.py +158 -0
  87. agentfox/gateway/routes/provenance.py +345 -0
  88. agentfox/gateway/routes/registry.py +1034 -0
  89. agentfox/gateway/routes/tuning.py +263 -0
  90. agentfox/gateway/routes/waitlist.py +183 -0
  91. agentfox/gateway/verdicts.py +70 -0
  92. agentfox/guardrails/__init__.py +107 -0
  93. agentfox/guardrails/actions.py +865 -0
  94. agentfox/guardrails/adapters/__init__.py +0 -0
  95. agentfox/guardrails/adapters/classifiers.py +362 -0
  96. agentfox/guardrails/adapters/embeddings.py +158 -0
  97. agentfox/guardrails/adapters/hub.py +356 -0
  98. agentfox/guardrails/adapters/presidio.py +121 -0
  99. agentfox/guardrails/adapters/rails.py +119 -0
  100. agentfox/guardrails/base.py +258 -0
  101. agentfox/guardrails/composition.py +150 -0
  102. agentfox/guardrails/data/injection_corpus.json +239 -0
  103. agentfox/guardrails/detectors/__init__.py +0 -0
  104. agentfox/guardrails/detectors/injection.py +627 -0
  105. agentfox/guardrails/detectors/pii.py +162 -0
  106. agentfox/guardrails/detectors/safety.py +96 -0
  107. agentfox/guardrails/detectors/schema.py +145 -0
  108. agentfox/guardrails/detectors/secrets.py +128 -0
  109. agentfox/guardrails/normalize.py +756 -0
  110. agentfox/guardrails/pipeline.py +262 -0
  111. agentfox/guardrails/taint.py +183 -0
  112. agentfox/guardrails/tuning.py +907 -0
  113. agentfox/identity/__init__.py +41 -0
  114. agentfox/identity/service.py +620 -0
  115. agentfox/ids.py +57 -0
  116. agentfox/improvement/__init__.py +7 -0
  117. agentfox/improvement/appliers.py +445 -0
  118. agentfox/improvement/contract.py +121 -0
  119. agentfox/improvement/loops.py +235 -0
  120. agentfox/improvement/proposals.py +781 -0
  121. agentfox/integrations/__init__.py +39 -0
  122. agentfox/integrations/correlation.py +360 -0
  123. agentfox/integrations/fastapi.py +235 -0
  124. agentfox/integrations/langgraph.py +393 -0
  125. agentfox/integrations/mcp.py +440 -0
  126. agentfox/integrations/prometheus.py +226 -0
  127. agentfox/integrity.py +1024 -0
  128. agentfox/job_handlers.py +278 -0
  129. agentfox/jobs.py +178 -0
  130. agentfox/jobs_db.py +312 -0
  131. agentfox/mcp_server.py +863 -0
  132. agentfox/models.py +1802 -0
  133. agentfox/operator_log.py +235 -0
  134. agentfox/policies_data/baseline.yaml +165 -0
  135. agentfox/policies_data/eu-ai-act-high-risk.yaml +113 -0
  136. agentfox/policies_data/tool-containment.yaml +197 -0
  137. agentfox/policy/__init__.py +101 -0
  138. agentfox/policy/canary.py +401 -0
  139. agentfox/policy/engine.py +315 -0
  140. agentfox/policy/hierarchy.py +354 -0
  141. agentfox/policy/model.py +262 -0
  142. agentfox/policy/opa.py +248 -0
  143. agentfox/policy/simulate.py +185 -0
  144. agentfox/policy/store.py +287 -0
  145. agentfox/provenance.py +560 -0
  146. agentfox/providers/__init__.py +43 -0
  147. agentfox/providers/base.py +192 -0
  148. agentfox/providers/echo.py +146 -0
  149. agentfox/providers/enterprise.py +366 -0
  150. agentfox/providers/remote.py +299 -0
  151. agentfox/register.py +281 -0
  152. agentfox/registry/__init__.py +43 -0
  153. agentfox/registry/control.py +161 -0
  154. agentfox/registry/service.py +838 -0
  155. agentfox/reliability.py +343 -0
  156. agentfox/scheduler.py +224 -0
  157. agentfox/sdk/__init__.py +481 -0
  158. agentfox/seed.py +660 -0
  159. agentfox/session_scan.py +124 -0
  160. agentfox/sycophancy.py +181 -0
  161. agentfox/system_log.py +132 -0
  162. agentfox/tenancy.py +298 -0
  163. agentfox/tool_contract.py +264 -0
  164. agentfox/trajectory.py +457 -0
  165. agentfox/webhooks.py +390 -0
  166. agentfox-0.3.1.dist-info/METADATA +542 -0
  167. agentfox-0.3.1.dist-info/RECORD +171 -0
  168. agentfox-0.3.1.dist-info/WHEEL +4 -0
  169. agentfox-0.3.1.dist-info/entry_points.txt +3 -0
  170. agentfox-0.3.1.dist-info/licenses/LICENSE +202 -0
  171. nometria/__init__.py +62 -0
agentfox/__init__.py ADDED
@@ -0,0 +1,45 @@
1
+ """AgentFox — agent-native, vendor-neutral governance for AI agents in production.
2
+
3
+ The whole product, from a developer's point of view, is one line:
4
+
5
+ import agentfox
6
+ agentfox.auto()
7
+
8
+ Every model call in the process is then traced, evaluated against policy, and written
9
+ to a tamper-evident audit log — in **observe mode**, blocking nothing, until someone
10
+ decides otherwise. Nothing else in the codebase changes.
11
+
12
+ Everything below that is depth for teams that want it: an SDK with explicit sessions
13
+ and taint tracking, a LangGraph-native guard, a gateway for non-Python stacks, and a
14
+ control plane. The one-liner exists because the sum of small integration asks is why
15
+ governance tooling sits in a proof-of-concept for six months.
16
+ """
17
+
18
+ __version__ = "0.3.1"
19
+
20
+ # Lazily re-exported so `import agentfox` stays fast and side-effect free — importing
21
+ # the package must never open a database or touch a client library.
22
+ _LAZY = {
23
+ "auto": ("agentfox.autoguard", "auto"),
24
+ "off": ("agentfox.autoguard", "off"),
25
+ "state": ("agentfox.autoguard", "state"),
26
+ "Blocked": ("agentfox.autoguard", "Blocked"),
27
+ "AgentFox": ("agentfox.sdk", "AgentFox"),
28
+ "PolicyViolation": ("agentfox.sdk", "PolicyViolation"),
29
+ "ApprovalRequired": ("agentfox.sdk", "ApprovalRequired"),
30
+ }
31
+
32
+ __all__ = ["__version__", *sorted(_LAZY)]
33
+
34
+
35
+ def __getattr__(name: str):
36
+ if name in _LAZY:
37
+ import importlib
38
+
39
+ module, attribute = _LAZY[name]
40
+ return getattr(importlib.import_module(module), attribute)
41
+ raise AttributeError(f"module 'agentfox' has no attribute {name!r}")
42
+
43
+
44
+ def __dir__() -> list[str]:
45
+ return sorted(__all__)
agentfox/agent_loop.py ADDED
@@ -0,0 +1,222 @@
1
+ """Governing the loop, not just the step.
2
+
3
+ Every control in this system is invoked per call: is this tool permitted, is this
4
+ argument tainted, is this statement destructive. The enforcer already counts how often
5
+ one tool has been called and how deep the path has gone, which catches the simplest
6
+ runaway — the same tool, over and over.
7
+
8
+ It does not catch the shapes that actually occur. An agent alternating between two
9
+ tools never repeats either one three times in a row. An agent re-issuing a query it
10
+ already answered makes progress by every counter that exists and none at all in fact.
11
+ An agent that keeps calling *different* tools while the world does not change looks,
12
+ step by step, exactly like an agent working.
13
+
14
+ What is missing is a view of the run rather than the step. That view is cheap to
15
+ maintain and answers a different question: not "is this call allowed" but "is this run
16
+ getting anywhere". The three signals below are the ones visible without understanding
17
+ the task, which is the constraint that makes this deterministic:
18
+
19
+ * **Repetition of a call.** The same tool with the same arguments, twice. Whatever the
20
+ first one returned is still true; the second is the agent asking again because it did
21
+ not know what to do with the answer.
22
+ * **Cycles in the sequence.** A→B→A→B is a loop that per-tool counting cannot see,
23
+ because neither tool has repeated consecutively.
24
+ * **Absence of new state.** Steps continuing while every observation matches one
25
+ already seen. This is the general case of the other two and the one that catches
26
+ shapes nobody enumerated.
27
+
28
+ Stopping is a verdict with a reason attached, not a silent cap. An agent halted at step
29
+ 40 with "budget exhausted" tells an operator nothing; halted with "steps 12-40 produced
30
+ no observation that had not already been seen" tells them where to look.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import hashlib
36
+ import json
37
+ from dataclasses import dataclass, field
38
+ from typing import Any
39
+
40
+ from .effects import _canonical
41
+
42
+ CONTINUE = "continue"
43
+ STOP = "stop"
44
+ ESCALATE = "escalate"
45
+
46
+
47
+ def _fingerprint(value: Any) -> str:
48
+ return hashlib.sha256(
49
+ json.dumps(value, sort_keys=True, default=str, separators=(",", ":")).encode()
50
+ ).hexdigest()[:16]
51
+
52
+
53
+ @dataclass
54
+ class Step:
55
+ """One tool call and what came back."""
56
+
57
+ tool: str
58
+ arguments: dict[str, Any] = field(default_factory=dict)
59
+ observation: Any = None
60
+
61
+ @property
62
+ def call_fingerprint(self) -> str:
63
+ return _fingerprint({"tool": self.tool, "arguments": self.arguments})
64
+
65
+ @property
66
+ def observation_fingerprint(self) -> str:
67
+ # Canonicalized first (effects._canonical: stable dict ordering, float
68
+ # formatting, whitespace) so two observations that are the same result in
69
+ # every way that matters — a timestamp or request id aside — fingerprint
70
+ # identically. Without this, "no new observation" only ever caught a
71
+ # byte-for-byte repeat, which a cosmetic diff defeats trivially.
72
+ return _fingerprint(_canonical(self.observation))
73
+
74
+
75
+ @dataclass
76
+ class LoopBudget:
77
+ """The declared shape of a legitimate run."""
78
+
79
+ max_steps: int = 25
80
+ max_repeats: int = 2
81
+ #: Length of the repeating sequence to look for. 2 catches A-B-A-B, 3 catches
82
+ #: A-B-C-A-B-C. Above 4 the pattern is usually a legitimate pipeline.
83
+ max_cycle_length: int = 4
84
+ #: Consecutive steps producing nothing new before the run is considered stuck.
85
+ max_steps_without_progress: int = 5
86
+ #: When set, the effective `max_steps_without_progress` threshold decays
87
+ #: linearly from its full value at this step index down to 1 at `max_steps` —
88
+ #: a step deep into a long run is judged more strictly than an early one,
89
+ #: using only the step index already tracked, no new state. `None` (the
90
+ #: default) keeps the threshold flat, exactly as before this existed.
91
+ decay_from_depth: int | None = None
92
+
93
+ def effective_max_steps_without_progress(self, index: int) -> int:
94
+ if self.decay_from_depth is None or index <= self.decay_from_depth:
95
+ return self.max_steps_without_progress
96
+ span = max(1, self.max_steps - self.decay_from_depth)
97
+ progressed = min(index - self.decay_from_depth, span)
98
+ decayed = self.max_steps_without_progress * (1 - progressed / span)
99
+ return max(1, round(decayed))
100
+
101
+
102
+ @dataclass
103
+ class LoopVerdict:
104
+ decision: str
105
+ reason: str = ""
106
+ step: int = 0
107
+ evidence: dict[str, Any] = field(default_factory=dict)
108
+
109
+ @property
110
+ def stopped(self) -> bool:
111
+ return self.decision != CONTINUE
112
+
113
+ def to_json(self) -> dict[str, Any]:
114
+ return {"decision": self.decision, "reason": self.reason, "step": self.step,
115
+ "evidence": self.evidence}
116
+
117
+
118
+ class LoopGovernor:
119
+ """Watches a run and says when it has stopped getting anywhere.
120
+
121
+ Stateful by necessity — every signal here is about the relationship between steps,
122
+ which is exactly what a per-call control cannot see.
123
+ """
124
+
125
+ def __init__(self, budget: LoopBudget | None = None) -> None:
126
+ self.budget = budget or LoopBudget()
127
+ self.steps: list[Step] = []
128
+ self._seen_observations: set[str] = set()
129
+ self._steps_without_progress = 0
130
+
131
+ def observe(self, step: Step) -> LoopVerdict:
132
+ """Record a step and decide whether the run may continue."""
133
+ self.steps.append(step)
134
+ index = len(self.steps)
135
+
136
+ if step.observation_fingerprint in self._seen_observations:
137
+ self._steps_without_progress += 1
138
+ else:
139
+ self._seen_observations.add(step.observation_fingerprint)
140
+ self._steps_without_progress = 0
141
+
142
+ if index > self.budget.max_steps:
143
+ return LoopVerdict(
144
+ STOP, f"the run passed its {self.budget.max_steps}-step budget", index,
145
+ {"steps": index},
146
+ )
147
+
148
+ repeats = sum(
149
+ 1 for s in self.steps if s.call_fingerprint == step.call_fingerprint
150
+ )
151
+ if repeats > self.budget.max_repeats:
152
+ return LoopVerdict(
153
+ STOP,
154
+ f"'{step.tool}' has been called {repeats} times with identical "
155
+ "arguments. Whatever it returned the first time is still true; the "
156
+ "agent is asking again because it did not know what to do with the "
157
+ "answer",
158
+ index, {"tool": step.tool, "repeats": repeats},
159
+ )
160
+
161
+ if cycle := self._cycle():
162
+ return LoopVerdict(
163
+ STOP,
164
+ f"the sequence {' → '.join(cycle)} is repeating. Per-tool counting "
165
+ "cannot see this: neither tool repeats consecutively",
166
+ index, {"cycle": cycle},
167
+ )
168
+
169
+ threshold = self.budget.effective_max_steps_without_progress(index)
170
+ if self._steps_without_progress >= threshold:
171
+ first = index - self._steps_without_progress
172
+ return LoopVerdict(
173
+ ESCALATE,
174
+ f"steps {first}-{index} produced no observation that had not already "
175
+ "been seen. The run is active and not advancing",
176
+ index,
177
+ {"steps_without_progress": self._steps_without_progress,
178
+ "from_step": first, "to_step": index},
179
+ )
180
+
181
+ return LoopVerdict(CONTINUE, step=index)
182
+
183
+ def _cycle(self) -> list[str] | None:
184
+ """The shortest repeating tail, if the sequence has one."""
185
+ tools = [s.tool for s in self.steps]
186
+ for length in range(2, self.budget.max_cycle_length + 1):
187
+ if len(tools) < length * 2:
188
+ continue
189
+ tail, before = tools[-length:], tools[-length * 2:-length]
190
+ if tail == before and len(set(tail)) > 1:
191
+ return tail
192
+ return None
193
+
194
+ def summary(self) -> dict[str, Any]:
195
+ return {
196
+ "steps": len(self.steps),
197
+ "distinct_observations": len(self._seen_observations),
198
+ "steps_without_progress": self._steps_without_progress,
199
+ "tools": [s.tool for s in self.steps],
200
+ }
201
+
202
+
203
+ def govern_loop(
204
+ steps: list[Step] | list[dict[str, Any]], *, budget: LoopBudget | None = None
205
+ ) -> LoopVerdict:
206
+ """Replay a completed or in-flight run and return the first verdict that stopped it.
207
+
208
+ Convenience over :class:`LoopGovernor` for callers holding a trace rather than
209
+ driving the loop — the offline path used by attribution and evaluation.
210
+ """
211
+ governor = LoopGovernor(budget)
212
+ last = LoopVerdict(CONTINUE)
213
+ for raw in steps:
214
+ step = raw if isinstance(raw, Step) else Step(
215
+ tool=str(raw.get("tool", "")),
216
+ arguments=raw.get("arguments") or {},
217
+ observation=raw.get("observation"),
218
+ )
219
+ last = governor.observe(step)
220
+ if last.stopped:
221
+ return last
222
+ return last
@@ -0,0 +1,90 @@
1
+ """Inter-agent message signing — NOM-IAM-08, closes OWASP ASI07.
2
+
3
+ Every ``surface`` value before this module was ``input``/``output``/``tool_args``/
4
+ ``tool_result``/``retrieved``; a sub-agent's output was folded into ``tool_result``
5
+ (same governance as a tool call's return value, not a boundary of its own). This
6
+ module is the signing half of giving agent-to-agent traffic its own boundary —
7
+ see :meth:`agentfox.enforcement.Enforcer.guard_agent_message` for the surface,
8
+ replay and agent-card checks that use it.
9
+
10
+ One HMAC-SHA256 secret per agent, encrypted at rest with the same primitive
11
+ :mod:`crypto` already uses for a connected GitHub token. Signing is a shared
12
+ secret between the agent and AgentFox's enforcement layer — AgentFox does not
13
+ need to *be* the transport to verify a message, only to hold the same key the
14
+ sender signed with.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ import hmac
21
+ import secrets as _secrets
22
+ import time
23
+
24
+ from sqlalchemy import select
25
+ from sqlalchemy.orm import Session
26
+
27
+ from .crypto import decrypt_secret, encrypt_secret
28
+ from .models import AgentSigningKey
29
+
30
+ #: A signature older than this is rejected even if it verifies — bounds how long a
31
+ #: captured signature/nonce pair remains a usable replay target before the anti-
32
+ #: replay table (unique on sender+nonce) is even consulted.
33
+ DEFAULT_VALIDITY_SECONDS = 300
34
+
35
+
36
+ def mint_signing_key(
37
+ session: Session, agent_id: str, *, created_by: str | None = None
38
+ ) -> tuple[AgentSigningKey, str]:
39
+ """Generate (or rotate) an agent's signing key.
40
+
41
+ Returns the raw key alongside the row — shown once, like an API token
42
+ (``ApiToken``/``TokenManager``): nothing before this point could sign as this
43
+ agent, and nothing after this point can retrieve the raw value again.
44
+ """
45
+ raw = _secrets.token_hex(32)
46
+ existing = session.scalar(select(AgentSigningKey).where(AgentSigningKey.agent_id == agent_id))
47
+ if existing is not None:
48
+ existing.key_encrypted = encrypt_secret(raw)
49
+ existing.created_by = created_by
50
+ existing.revoked_at = None
51
+ key = existing
52
+ else:
53
+ key = AgentSigningKey(agent_id=agent_id, key_encrypted=encrypt_secret(raw), created_by=created_by)
54
+ session.add(key)
55
+ session.flush()
56
+ return key, raw
57
+
58
+
59
+ def _canonical(sender: str, nonce: str, timestamp: float, payload: str) -> bytes:
60
+ # Fixed field order + explicit separators so signer and verifier never
61
+ # disagree on what was actually signed.
62
+ return f"{sender}\n{nonce}\n{timestamp:.6f}\n{payload}".encode()
63
+
64
+
65
+ def sign_message(raw_key: str, *, sender: str, nonce: str, payload: str, timestamp: float | None = None) -> tuple[str, float]:
66
+ ts = timestamp if timestamp is not None else time.time()
67
+ mac = hmac.new(raw_key.encode(), _canonical(sender, nonce, ts, payload), hashlib.sha256)
68
+ return mac.hexdigest(), ts
69
+
70
+
71
+ def verify_message(
72
+ raw_key: str,
73
+ *,
74
+ sender: str,
75
+ nonce: str,
76
+ payload: str,
77
+ timestamp: float,
78
+ signature: str,
79
+ validity_seconds: int = DEFAULT_VALIDITY_SECONDS,
80
+ ) -> bool:
81
+ if abs(time.time() - timestamp) > validity_seconds:
82
+ return False
83
+ expected = hmac.new(raw_key.encode(), _canonical(sender, nonce, timestamp, payload), hashlib.sha256).hexdigest()
84
+ # constant-time compare — a timing side-channel here would leak the signature
85
+ # byte by byte, defeating the point of signing at all.
86
+ return hmac.compare_digest(expected, signature)
87
+
88
+
89
+ def decrypt_signing_key(key_row: AgentSigningKey) -> str:
90
+ return decrypt_secret(key_row.key_encrypted)