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.
- agentfox/__init__.py +45 -0
- agentfox/agent_loop.py +222 -0
- agentfox/agent_messaging.py +90 -0
- agentfox/answerability.py +605 -0
- agentfox/arbitration.py +309 -0
- agentfox/attribution.py +538 -0
- agentfox/audit/__init__.py +47 -0
- agentfox/audit/chain.py +420 -0
- agentfox/audit/evidence.py +560 -0
- agentfox/audit/otel.py +160 -0
- agentfox/audit/siem.py +233 -0
- agentfox/audit/trace.py +337 -0
- agentfox/autoguard.py +1261 -0
- agentfox/availability.py +702 -0
- agentfox/business/__init__.py +43 -0
- agentfox/business/catalogue.py +583 -0
- agentfox/business/compile.py +1010 -0
- agentfox/business/graph.py +314 -0
- agentfox/business/ladder.py +396 -0
- agentfox/business/store.py +159 -0
- agentfox/cli/__init__.py +5 -0
- agentfox/cli/_style.py +40 -0
- agentfox/cli/auth_cli.py +222 -0
- agentfox/cli/business_cli.py +403 -0
- agentfox/cli/capability_cli.py +475 -0
- agentfox/cli/controls_cli.py +516 -0
- agentfox/cli/demo.py +662 -0
- agentfox/cli/main.py +2065 -0
- agentfox/cli/mcp_cli.py +39 -0
- agentfox/cli/onboarding.py +682 -0
- agentfox/cli/quickscan.py +187 -0
- agentfox/cli/submit.py +120 -0
- agentfox/commitments.py +496 -0
- agentfox/compliance/__init__.py +43 -0
- agentfox/compliance/catalog.py +250 -0
- agentfox/compliance/risk.py +380 -0
- agentfox/compliance/status.py +524 -0
- agentfox/compliance_data/controls.yaml +944 -0
- agentfox/compliance_data/obligations.yaml +91 -0
- agentfox/config.py +502 -0
- agentfox/context_integrity.py +609 -0
- agentfox/control_flow.py +164 -0
- agentfox/crypto.py +45 -0
- agentfox/data_access.py +384 -0
- agentfox/db.py +233 -0
- agentfox/discovery.py +847 -0
- agentfox/discovery_openapi.py +113 -0
- agentfox/effects.py +461 -0
- agentfox/enforcement.py +3161 -0
- agentfox/entitlement.py +464 -0
- agentfox/escalation.py +826 -0
- agentfox/evaluation/__init__.py +98 -0
- agentfox/evaluation/adapters.py +189 -0
- agentfox/evaluation/adaptive.py +1098 -0
- agentfox/evaluation/drift.py +303 -0
- agentfox/evaluation/gating.py +309 -0
- agentfox/evaluation/model_groundedness.py +127 -0
- agentfox/evaluation/ragas_adapter.py +241 -0
- agentfox/evaluation/redteam.py +1280 -0
- agentfox/evaluation/runner.py +366 -0
- agentfox/evaluation/scorers.py +350 -0
- agentfox/evaluation/silent_failure.py +438 -0
- agentfox/finding.py +40 -0
- agentfox/findings.py +377 -0
- agentfox/gateway/__init__.py +5 -0
- agentfox/gateway/app.py +555 -0
- agentfox/gateway/auth.py +307 -0
- agentfox/gateway/deps.py +126 -0
- agentfox/gateway/playground_sessions.py +414 -0
- agentfox/gateway/routes/__init__.py +1 -0
- agentfox/gateway/routes/answerability.py +165 -0
- agentfox/gateway/routes/discovery.py +99 -0
- agentfox/gateway/routes/entitlement.py +213 -0
- agentfox/gateway/routes/escalation.py +326 -0
- agentfox/gateway/routes/evaluation.py +798 -0
- agentfox/gateway/routes/governance.py +675 -0
- agentfox/gateway/routes/inline.py +964 -0
- agentfox/gateway/routes/integrations.py +608 -0
- agentfox/gateway/routes/jobs.py +145 -0
- agentfox/gateway/routes/memory.py +101 -0
- agentfox/gateway/routes/messaging.py +82 -0
- agentfox/gateway/routes/onboarding.py +293 -0
- agentfox/gateway/routes/playground.py +272 -0
- agentfox/gateway/routes/playground_deps.py +34 -0
- agentfox/gateway/routes/policy.py +512 -0
- agentfox/gateway/routes/proposals.py +158 -0
- agentfox/gateway/routes/provenance.py +345 -0
- agentfox/gateway/routes/registry.py +1034 -0
- agentfox/gateway/routes/tuning.py +263 -0
- agentfox/gateway/routes/waitlist.py +183 -0
- agentfox/gateway/verdicts.py +70 -0
- agentfox/guardrails/__init__.py +107 -0
- agentfox/guardrails/actions.py +865 -0
- agentfox/guardrails/adapters/__init__.py +0 -0
- agentfox/guardrails/adapters/classifiers.py +362 -0
- agentfox/guardrails/adapters/embeddings.py +158 -0
- agentfox/guardrails/adapters/hub.py +356 -0
- agentfox/guardrails/adapters/presidio.py +121 -0
- agentfox/guardrails/adapters/rails.py +119 -0
- agentfox/guardrails/base.py +258 -0
- agentfox/guardrails/composition.py +150 -0
- agentfox/guardrails/data/injection_corpus.json +239 -0
- agentfox/guardrails/detectors/__init__.py +0 -0
- agentfox/guardrails/detectors/injection.py +627 -0
- agentfox/guardrails/detectors/pii.py +162 -0
- agentfox/guardrails/detectors/safety.py +96 -0
- agentfox/guardrails/detectors/schema.py +145 -0
- agentfox/guardrails/detectors/secrets.py +128 -0
- agentfox/guardrails/normalize.py +756 -0
- agentfox/guardrails/pipeline.py +262 -0
- agentfox/guardrails/taint.py +183 -0
- agentfox/guardrails/tuning.py +907 -0
- agentfox/identity/__init__.py +41 -0
- agentfox/identity/service.py +620 -0
- agentfox/ids.py +57 -0
- agentfox/improvement/__init__.py +7 -0
- agentfox/improvement/appliers.py +445 -0
- agentfox/improvement/contract.py +121 -0
- agentfox/improvement/loops.py +235 -0
- agentfox/improvement/proposals.py +781 -0
- agentfox/integrations/__init__.py +39 -0
- agentfox/integrations/correlation.py +360 -0
- agentfox/integrations/fastapi.py +235 -0
- agentfox/integrations/langgraph.py +393 -0
- agentfox/integrations/mcp.py +440 -0
- agentfox/integrations/prometheus.py +226 -0
- agentfox/integrity.py +1024 -0
- agentfox/job_handlers.py +278 -0
- agentfox/jobs.py +178 -0
- agentfox/jobs_db.py +312 -0
- agentfox/mcp_server.py +863 -0
- agentfox/models.py +1802 -0
- agentfox/operator_log.py +235 -0
- agentfox/policies_data/baseline.yaml +165 -0
- agentfox/policies_data/eu-ai-act-high-risk.yaml +113 -0
- agentfox/policies_data/tool-containment.yaml +197 -0
- agentfox/policy/__init__.py +101 -0
- agentfox/policy/canary.py +401 -0
- agentfox/policy/engine.py +315 -0
- agentfox/policy/hierarchy.py +354 -0
- agentfox/policy/model.py +262 -0
- agentfox/policy/opa.py +248 -0
- agentfox/policy/simulate.py +185 -0
- agentfox/policy/store.py +287 -0
- agentfox/provenance.py +560 -0
- agentfox/providers/__init__.py +43 -0
- agentfox/providers/base.py +192 -0
- agentfox/providers/echo.py +146 -0
- agentfox/providers/enterprise.py +366 -0
- agentfox/providers/remote.py +299 -0
- agentfox/register.py +281 -0
- agentfox/registry/__init__.py +43 -0
- agentfox/registry/control.py +161 -0
- agentfox/registry/service.py +838 -0
- agentfox/reliability.py +343 -0
- agentfox/scheduler.py +224 -0
- agentfox/sdk/__init__.py +481 -0
- agentfox/seed.py +660 -0
- agentfox/session_scan.py +124 -0
- agentfox/sycophancy.py +181 -0
- agentfox/system_log.py +132 -0
- agentfox/tenancy.py +298 -0
- agentfox/tool_contract.py +264 -0
- agentfox/trajectory.py +457 -0
- agentfox/webhooks.py +390 -0
- agentfox-0.3.1.dist-info/METADATA +542 -0
- agentfox-0.3.1.dist-info/RECORD +171 -0
- agentfox-0.3.1.dist-info/WHEEL +4 -0
- agentfox-0.3.1.dist-info/entry_points.txt +3 -0
- agentfox-0.3.1.dist-info/licenses/LICENSE +202 -0
- 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)
|