Skip to content

Commit 1054c40

Browse files
committed
fix(agent-mesh): skip flask-dependent raw-target tests without flask
Two tests added with the raw request-target work imported Flask through a shared helper while the file's `flask` skip marker was defined below them, so they ran unguarded and failed collection wherever Flask is absent. That broke `test (agent-mesh, 3.11/3.12/3.13)` and `docker-compose-test`. Move the existing marker above its first use and apply it to just those two tests, not the class, so the three raw-target regressions that need no framework keep running everywhere. Verified in a Flask-free virtualenv: the two tests skip and the rest pass. Also document the v2 signing envelope: undecoded request target, target mode, server-chosen covered headers, verification deadlines, and `install_fastapi_trust`. Drop the four references to the HTTP middleware guide that pointed at a file which was never tracked. Signed-off-by: Prayag Upadhyay <prayag.upd@gmail.com>
1 parent dc9c69e commit 1054c40

8 files changed

Lines changed: 631 additions & 16 deletions

File tree

BREAKING_CHANGES.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,64 @@ entries appear first.
55

66
---
77

8+
## `TrustMiddleware` requires signed requests and an explicit trust anchor
9+
10+
**Date:** TBD
11+
12+
**Affected**
13+
14+
- `agentmesh.integrations.TrustMiddleware` and the Flask/FastAPI decorators
15+
`flask_trust_required` / `fastapi_trust_required`
16+
- any client that authenticated by sending only an `X-Agent-DID` header
17+
18+
**What changed**
19+
20+
`TrustMiddleware` treated a caller-supplied `X-Agent-DID` header as proof of
21+
identity: `verify_request` started from a trust score of `1.0` and only lowered
22+
it inside an `except` branch that could never run, because
23+
`AgentIdentity.verify_signature` returns a `bool` and never raises. Any caller
24+
who set the header was verified with full trust, and `X-Agent-Capabilities` was
25+
honoured as self-asserted authorization.
26+
27+
Callers now prove possession of a registered Ed25519 key over a canonical
28+
envelope binding DID, audience, timestamp, nonce, method, undecoded request
29+
target, target mode, covered request headers, and body digest, with single-use
30+
nonce replay protection. Verification keys and capabilities come only from a
31+
peer resolver, never from headers — `did:mesh` identifiers are random rather
32+
than key-derived, so a DID is not self-certifying and a presented public key can
33+
never authenticate anyone.
34+
35+
**How to update**
36+
37+
Servers:
38+
39+
| Before | After |
40+
|--------|-------|
41+
| `TrustMiddleware(identity)` | `TrustMiddleware.from_registry(registry, TrustConfig(audience=...))`; `audience`, `peer_resolver` and `replay_cache` are required and raise `ValueError` when missing |
42+
| `verify_request(headers)` | Pass `method`, `request_target` and `body`; omitting them fails closed with `500` |
43+
| `TrustConfig(permissive_mode=True)` | Also set `required_trust_score=0.0` and remove `required_capabilities` |
44+
| Anonymous callers reached `*_trust_required` | Use `flask_trust_optional` / `fastapi_trust_optional` and branch on `result.authenticated` |
45+
| `err["reason"]` on a `401` | Removed from the client-visible body; read `result.reason` server-side |
46+
| `VerificationResult` was mutable | Now frozen, and gained `authenticated` |
47+
| `TrustConfig` was mutable | Now frozen; assigning a field after construction raises `FrozenInstanceError` |
48+
| `TrustConfig(required_capabilities="admin")` | Rejected with `ValueError`; pass a sequence such as `("admin",)`. A bare string was silently expanded into five single-character capabilities |
49+
| A raising `peer_resolver` yielded `401` | Now `503`. A registry outage is a server fault, not a credential failure |
50+
| Custom `replay_cache` returned `False` when full | Must now raise `ReplayCacheFull`, which yields `503`. `False` still means "nonce already used" and yields `401` |
51+
| FastAPI body limit | Build the dependency with `install_fastapi_trust(app, middleware)`, which installs the pre-routing `SignedBodyLimitMiddleware` guard and binds the dependency to it. The dependency alone runs after FastAPI has buffered the body, and now fails closed with `500` if the guard is absent |
52+
| The signed target was the percent-decoded path | It is now the undecoded target, read from `RAW_URI`/`REQUEST_URI`/`scope["raw_path"]`. Servers that publish none of these fail with `500` until you set `request_target_mode="decoded"` (or `AGENTMESH_REQUEST_TARGET_MODE`). Django's `runserver` and `RequestFactory` are in this group; gunicorn, uWSGI and mod_wsgi are not |
53+
| `build_request_signature_payload(..., content_type=...)` | Pass `target_mode=` and `signed_headers=` instead. Covered headers are chosen by the server via `TrustConfig.signed_header_names` (default `("content-type",)`), and an absent header is omitted rather than signed as `""` |
54+
| Verification had no time bound | `TrustConfig.io_timeout_seconds` (default `5.0`) budgets the whole verification. Resolvers and replay caches that declare a `timeout_seconds` parameter receive the remaining budget; exhaustion denies with `503` before the nonce is consumed |
55+
| Django exempt views saw `request.agent_did` | Exempt views and exempt path prefixes verify nothing and now set `agent_did=None`, `agent_trust_score=None`, `agent_authenticated=False`. Check `request.agent_authenticated` first |
56+
57+
Clients must sign each request; `build_request_signature_payload` in
58+
`agent-governance-python/agent-mesh/src/agentmesh/integrations/request_auth.py`
59+
builds the canonical envelope and is the authority on its contents.
60+
61+
Both sides must be upgraded together: an unpatched client cannot authenticate
62+
against a patched server, by design.
63+
64+
---
65+
866
## `HostSession.post_tool_call` and `pre_model_call` emit the adapter snapshot shape
967

1068
**Date:** TBD

agent-governance-python/agent-mesh/AGENTS.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ black --check .
6060
- All data models use Pydantic `BaseModel`
6161
- Identity: `AgentIdentity` with `.sign(data: bytes) -> str` (base64 Ed25519 signatures)
6262
- Trust scores: 5 dimensions — competence, integrity, availability, predictability, transparency
63-
- DID format: `did:mesh:{hex}` — derived from public key
63+
- DID format: `did:mesh:{hex}` — see the proof-of-possession note under Boundaries: `registry/app.py`
64+
derives DIDs from the public key, while `AgentDID.generate` mints a random identifier
6465
- Private keys stored as `_private_key` (never serialized)
6566
- AICard: `from_identity()` creates signed cards; `from_trusted_agent_card()` bridges existing formats
6667
- Tests in `tests/` directory
@@ -70,7 +71,8 @@ black --check .
7071
- **Never serialize** private keys in JSON/YAML output
7172
- **Never commit** secrets, API keys, or credentials
7273
- **Never weaken** trust thresholds — only tighten
73-
- **Never accept public keys without proof-of-possession** — any HTTP endpoint that accepts a `public_key` or `verification_key` MUST verify the caller controls the corresponding private key via Ed25519 signature over `(key || timestamp)`. DIDs MUST be derived from `SHA-256(public_key)`, never client-supplied. See `registry/app.py` for the reference implementation. CI enforces this via `scripts/ci/no-unauthed-registration.sh`.
74+
- **Never accept public keys without proof-of-possession** — any HTTP endpoint that accepts a `public_key` or `verification_key` MUST verify the caller controls the corresponding private key via Ed25519 signature over `(key || timestamp)`. DIDs issued by the registry MUST be derived from `SHA-256(public_key)`, never client-supplied. See `registry/app.py` for the reference implementation. CI enforces this via `scripts/ci/no-unauthed-registration.sh`.
75+
- **A `did:mesh` identifier is not self-certifying**`AgentDID.generate` mints a random identifier (`secrets.token_hex(16)`, `identity/agent_id.py`), so it is *not* derived from the key and carries no cryptographic binding to one. Only registry-issued DIDs are key-derived. Consequently, request authentication MUST resolve the DID's key through a trusted registry and MUST NOT trust a caller-supplied public key, even when it "matches" the DID. See `integrations/request_auth.py`.
7476
- Keep backward compatibility with existing protocol messages
7577
- the repo root are standalone — changes there need their own test suite
7678

agent-governance-python/agent-mesh/CHANGELOG.md

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,92 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Security
1111

12+
- **HTTP trust middleware request authentication.** `TrustMiddleware` no longer
13+
accepts a caller-supplied `X-Agent-DID` header as proof of identity. It
14+
previously started from a trust score of `1.0` and only lowered it inside an
15+
`except` branch that could never execute, so any caller who set the header was
16+
verified with full trust and `X-Agent-Capabilities` was honoured as
17+
self-asserted authorization. Callers now prove possession of a registered
18+
Ed25519 key over a canonical envelope binding DID, audience, timestamp, nonce,
19+
method, undecoded request target, target mode, covered request headers, and
20+
body digest, with single-use nonce replay protection. Verification keys and
21+
capabilities are resolved only from a
22+
trusted registry, never from request headers. The Flask, FastAPI, and Django
23+
integrations now share one envelope implementation, and the header lookup is
24+
case-insensitive so the FastAPI path no longer silently misses the trust
25+
headers Starlette lowercases. See `BREAKING_CHANGES.md` — clients must be
26+
upgraded alongside servers.
27+
- **The signed target is the undecoded one.** Signing the percent-decoded path
28+
made `/files/a%2Fb` and `/files/a/b` produce identical signed bytes, so a
29+
signature captured for one was valid for the other wherever a proxy, gateway,
30+
or audit log reads the target before the application decodes it. All three
31+
integrations now sign the raw origin-form target from `RAW_URI`,
32+
`REQUEST_URI`, or `scope["raw_path"]`, and servers that publish none of these
33+
fail with `500` rather than silently downgrading. `target_mode` travels inside
34+
the signed bytes, so a raw↔decoded downgrade fails closed.
35+
- **Covered headers are chosen by the server and bind presence.** `Content-Type`
36+
was signed as `""` when absent, so an attacker could add one the signer never
37+
sent — enough to change how a body is parsed. Headers named by
38+
`TrustConfig.signed_header_names` are now omitted from the envelope when
39+
absent, so adding or stripping one invalidates the signature. The covered set
40+
is server-configured rather than caller-declared, so a caller cannot narrow
41+
its own coverage.
42+
- **Verification is time-bounded.** `TrustConfig.io_timeout_seconds` (default
43+
`5.0`) budgets an entire verification rather than each dependency call, so a
44+
slow peer resolver cannot hand its remaining time to a slow replay cache. The
45+
remaining budget is passed to resolvers and caches that declare a
46+
`timeout_seconds` parameter, the budget is re-checked before the nonce is
47+
consumed so a doomed request never burns a single-use nonce, and exhaustion
48+
denies with `503`.
49+
- **Django exempt routes no longer publish an unverified DID.** `@trust_exempt`
50+
views and exempt path prefixes copied the attacker-controlled `X-Agent-DID`
51+
header onto `request.agent_did`, so a view that logged or authorized on it
52+
received a value nothing had verified. Exempt and denied requests now set
53+
`agent_did=None`, `agent_trust_score=None`, and `agent_authenticated=False`.
54+
- **`@trust_required(min_score=0)` still requires authentication.** The Django
55+
gate compared only the trust score, so a zero floor admitted a caller whose
56+
signature had failed — a deliberately open route silently became an
57+
unauthenticated one. Authentication is now checked before the score.
58+
- **Anonymous callers cannot reach protected routes.** `flask_trust_required`
59+
and `fastapi_trust_required` reject any result that is not cryptographically
60+
authenticated, so a `permissive_mode` deployment no longer admits an
61+
unauthenticated caller to a route that requires capabilities. Serving
62+
anonymous callers now requires the explicitly-named `flask_trust_optional` /
63+
`fastapi_trust_optional` variants, and `TrustConfig` refuses to combine
64+
`permissive_mode` with an authorization gate.
65+
- **Unauthenticated request-handling hardening.** The signed body is read
66+
against `max_signed_body_bytes` before buffering, so an unauthenticated caller
67+
cannot force unbounded memory allocation; `install_fastapi_trust` installs the
68+
pre-routing body guard and returns a dependency bound to it, and that
69+
dependency now fails closed with `500` when the guard is absent rather than
70+
verifying a body FastAPI has already buffered without limit; a presented
71+
public key is compared as
72+
decoded bytes rather than base64 text, so a non-ASCII header value can no
73+
longer raise inside the auth path; `401` responses no longer disclose which
74+
DIDs are registered; DIDs are bounded and charset-checked before reaching a
75+
resolver or a log sink; replay-cache exhaustion is reported as `503` rather
76+
than being recorded as a replay attempt; and replay keys are namespaced by
77+
audience so services sharing a cache cannot burn each other's nonces.
78+
- **Server faults are no longer reported as authentication failures.** A peer
79+
resolver that raises, an unreadable request body, and any unexpected internal
80+
error now return `503` instead of `401`, so an identity-store outage cannot
81+
masquerade as every caller presenting bad credentials and stays visible to
82+
alerting keyed on server errors. `TrustConfig` is frozen so the
83+
`permissive_mode` guard cannot be voided by post-construction assignment, and
84+
it rejects a bare string for `required_capabilities`, which was previously
85+
expanded character-by-character into an unsatisfiable authorization gate.
86+
- **Denial telemetry and decorator hardening.** Pre-authentication failures stay
87+
at `DEBUG` so they cannot be used to flood logs, but each distinct denial
88+
reason now also emits a rate-limited `WARNING` carrying a coarse reason and
89+
status — never the DID — so an attack is visible at default log levels. The
90+
Flask and FastAPI decorators extend `verify_request`'s never-raise guarantee
91+
over the body read that precedes it, and both derive the signed request target
92+
from the raw query bytes, so a client disconnect or a non-UTF-8 query no longer
93+
produces a `500` and both frameworks sign identical payloads. Internal-fault
94+
tracebacks are emitted at most once per minute per call site, so a failing peer
95+
resolver cannot amplify one fault into an `ERROR` traceback per request, and a
96+
missing-capability denial is now logged with the agent's DID so privilege
97+
probing by an authenticated agent is visible.
1298
- **AgentMesh transport message authentication.** `MeshClient` no longer lets a
1399
sender-supplied `plaintext` wire flag select the legacy no-crypto receive path;
14100
whether an inbound message is treated as plaintext is decided solely by the

agent-governance-python/agent-mesh/docs/integrations/django-middleware.md

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,9 @@ Every authenticated request requires these headers:
5656
| `X-Agent-Nonce` | Unique base64url value containing 16 to 64 random bytes |
5757
| `X-Agent-Signature` | Base64-encoded Ed25519 signature of the canonical request payload |
5858

59-
Use `build_request_signature_payload` to produce the canonical bytes. The path must include the
60-
query string in its transmitted order, and `body` must contain the exact bytes sent on the wire.
59+
Use `build_request_signature_payload` to produce the canonical bytes. `request_target` must be the
60+
**undecoded** target exactly as it goes on the wire, including the query string in its transmitted
61+
order, and `body` must contain the exact bytes sent.
6162

6263
```python
6364
import base64
@@ -80,8 +81,9 @@ payload = build_request_signature_payload(
8081
nonce=nonce,
8182
method="POST",
8283
request_target="/v1/payments?mode=immediate",
84+
target_mode="raw",
8385
body=body,
84-
content_type=content_type,
86+
signed_headers={"content-type": content_type},
8587
)
8688
signature = base64.b64encode(private_key.sign(payload)).decode("ascii")
8789

@@ -94,9 +96,45 @@ headers = {
9496
}
9597
```
9698

97-
The signed envelope binds the DID, configured audience, timestamp, nonce, HTTP method, path and
98-
query, content type, and SHA-256 body digest. Changing any of these values invalidates the
99-
signature. A correctly signed nonce is accepted once and retained in the replay cache for the
100-
configured replay window. Cache errors fail closed.
99+
The signed envelope binds the DID, configured audience, timestamp, nonce, HTTP method, undecoded
100+
request target, target mode, covered request headers, and SHA-256 body digest. Changing any of
101+
these values invalidates the signature. A correctly signed nonce is accepted once and retained in
102+
the replay cache for the configured replay window. Cache errors fail closed with `503`.
103+
104+
Only headers that are actually present are covered, so *removing* `Content-Type` invalidates the
105+
signature just as surely as changing it. The covered set is chosen by the server
106+
(`AGENTMESH_SIGNED_HEADERS`), never declared by the caller — a caller who could pick which headers
107+
are signed could simply decline to sign the ones that matter.
108+
109+
### Undecoded request targets
110+
111+
`AGENTMESH_REQUEST_TARGET_MODE` defaults to `"raw"`, which signs the target as sent. This requires
112+
a WSGI server that publishes it as `RAW_URI` or `REQUEST_URI` — gunicorn, uWSGI, and mod_wsgi all
113+
do. Django's `runserver` and `RequestFactory` do **not**, so the middleware returns `500` with an
114+
actionable message rather than silently verifying something weaker.
115+
116+
For development, set:
117+
118+
```python
119+
AGENTMESH_REQUEST_TARGET_MODE = "decoded"
120+
```
121+
122+
`"decoded"` signs Django's percent-decoded `request.path`, which cannot distinguish `/files/a%2Fb`
123+
from `/files/a/b`. Both forms travel inside the signed bytes, so a signature produced in one mode
124+
is never accepted in the other.
125+
126+
### Identity attributes on the request
127+
128+
After the middleware runs, views can read:
129+
130+
| Attribute | Meaning |
131+
|-----------|---------|
132+
| `request.agent_authenticated` | `True` only when a signature verified |
133+
| `request.agent_did` | Verified DID, or `None` |
134+
| `request.agent_trust_score` | Verified score, or `None` |
135+
136+
Check `request.agent_authenticated` before acting on `request.agent_did`. Exempt views and exempt
137+
path prefixes verify nothing, so they set `agent_did` to `None` — the raw `X-Agent-DID` header is
138+
attacker-controlled and is never published to application code.
101139

102140
Signatures over only the agent DID are not accepted.

0 commit comments

Comments
 (0)