Skip to content

Commit 9c2e098

Browse files
vvillait88claude
andcommitted
hardening: round-6 reviewer findings (cross-lang corpus + extras + reject set)
Tighten cross-lang fixture corpus + extras-collision defenses + _reject_floats walking so cross-language byte parity has airtight regression coverage. Cross-lang fixture corpus: previous py-emoji-keys.json used chars (cafe, JP, wine glass, CJK Compat) where UTF-16 first-unit sort and Unicode codepoint sort produce identical canonical bodies, so the codepoint-sort fix was silently untested in the verifier-side parity check. Regenerates py-emoji-keys.json with a key set that genuinely distinguishes the two: BMP private use (U+E000, codepoint 57344) alongside supplementary-plane wine glass (U+1F377, codepoint 127863, UTF-16 first unit 55356). Codepoint sort puts U+E000 BEFORE U+1F377; UTF-16 first-unit sort reverses that. Both repos now ship both node-emoji-keys.json and py-emoji-keys.json in their fixture corpus so each repo's verifier validates the OTHER language's canonical body. A regression in either language's key sort surfaces here. UCPProfile.to_dict reserved set adds __class__, __dict__, __init__ for symmetry with node-commerce's prototype-pollution defense. Python's runtime model doesn't have JS-style __proto__ pollution but the reserved-name check guards against bidirectional vendor data passing through both SDKs and surprising downstream consumers. _reject_floats now walks set / frozenset in addition to list / tuple. A profile containing a set with a float would otherwise crash later in json.dumps with an untyped TypeError; catching it at the same surface as the list/tuple path keeps the error message consistent. README joserfc note adds tested-version pin (joserfc>=1.0.0,<2) mirroring node-commerce's "tested against jose v5.x; pin jose@^5". Tests: 800 pass + 3 skipped, 95.22% coverage. ruff + ty clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4373205 commit 9c2e098

8 files changed

Lines changed: 122 additions & 17 deletions

File tree

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,11 @@ profile = build_ucp_profile(
202202
)
203203
```
204204

205-
UCP §6 trust-mode requires profiles to carry a JWS signature backed by a JWKS at `/.well-known/jwks.json`. Sign + verify via the optional `joserfc` extra (`pip install agentscore-commerce[ucp]`):
205+
UCP §6 trust-mode requires profiles to carry a JWS signature backed by a JWKS at `/.well-known/jwks.json`. Sign + verify via the optional `joserfc` extra (tested against joserfc v1.x; pin `joserfc>=1.0.0,<2`):
206+
207+
```bash
208+
pip install agentscore-commerce[ucp]
209+
```
206210

207211
```python
208212
from agentscore_commerce.identity import (

agentscore_commerce/identity/ucp.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,9 @@ def to_dict(self) -> dict[str, Any]:
184184
# Filter `extras` so a caller passing
185185
# ``extras={"signing_keys": [...]}`` can't silently destroy the
186186
# explicit field. Reserved-field collisions are rejected at
187-
# build-time-equivalent surface.
187+
# build-time-equivalent surface. ``__class__`` / ``__dict__`` /
188+
# ``__init__`` mirror node-commerce's prototype-pollution defense
189+
# against bidirectional vendor data passing through both SDKs.
188190
reserved = {
189191
"version",
190192
"spec",
@@ -194,6 +196,9 @@ def to_dict(self) -> dict[str, Any]:
194196
"signing_keys",
195197
"name",
196198
"signature",
199+
"__class__",
200+
"__dict__",
201+
"__init__",
197202
}
198203
for k, v in self.extras.items():
199204
if k in reserved:

agentscore_commerce/identity/ucp_jwks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ def _reject_floats(value: Any) -> None:
179179
if isinstance(value, dict):
180180
for v in value.values():
181181
_reject_floats(v)
182-
elif isinstance(value, list | tuple):
182+
elif isinstance(value, list | tuple | set | frozenset):
183183
for v in value:
184184
_reject_floats(v)
185185

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
{
2+
"profile": {
3+
"version": "2026-04-17",
4+
"spec": "https://ucp.dev/",
5+
"services": [
6+
{
7+
"type": "rest",
8+
"url": "https://emoji.example.com"
9+
}
10+
],
11+
"capabilities": [],
12+
"payment_handlers": [
13+
{
14+
"name": "tempo",
15+
"config": {}
16+
}
17+
],
18+
"signing_keys": [
19+
{
20+
"kid": "node-emoji-keys-EdDSA",
21+
"alg": "EdDSA",
22+
"use": "sig",
23+
"crv": "Ed25519",
24+
"kty": "OKP",
25+
"x": "SEqAXr_hDfmdqLqepK--97NMkVlYF_A1ByPa2xycou8"
26+
}
27+
],
28+
"name": "Emoji Keys Merchant",
29+
"extras": {
30+
"a": 1,
31+
"豈": 2,
32+
"": 3,
33+
"🍷": 4
34+
},
35+
"signature": "eyJhbGciOiJFZERTQSIsImtpZCI6Im5vZGUtZW1vamkta2V5cy1FZERTQSIsInR5cCI6InVjcC1wcm9maWxlK2p3cyJ9.eyJjYXBhYmlsaXRpZXMiOltdLCJleHRyYXMiOnsiYSI6MSwi6LGIIjoyLCLugIAiOjMsIvCfjbciOjR9LCJuYW1lIjoiRW1vamkgS2V5cyBNZXJjaGFudCIsInBheW1lbnRfaGFuZGxlcnMiOlt7ImNvbmZpZyI6e30sIm5hbWUiOiJ0ZW1wbyJ9XSwic2VydmljZXMiOlt7InR5cGUiOiJyZXN0IiwidXJsIjoiaHR0cHM6Ly9lbW9qaS5leGFtcGxlLmNvbSJ9XSwic2lnbmluZ19rZXlzIjpbeyJhbGciOiJFZERTQSIsImNydiI6IkVkMjU1MTkiLCJraWQiOiJub2RlLWVtb2ppLWtleXMtRWREU0EiLCJrdHkiOiJPS1AiLCJ1c2UiOiJzaWciLCJ4IjoiU0VxQVhyX2hEZm1kcUxxZXBLLS05N05Na1ZsWUZfQTFCeVBhMnh5Y291OCJ9XSwic3BlYyI6Imh0dHBzOi8vdWNwLmRldi8iLCJ2ZXJzaW9uIjoiMjAyNi0wNC0xNyJ9.QD_zQMZ4UkUkuZQ-rNNEDrEalu2eYrI280Migljdk67UqHWMMOcB4nsBR9mj4E3RJ5M7sgAZ9CWWptdrcTqXCQ"
36+
},
37+
"jwks": {
38+
"keys": [
39+
{
40+
"kid": "node-emoji-keys-EdDSA",
41+
"alg": "EdDSA",
42+
"use": "sig",
43+
"crv": "Ed25519",
44+
"kty": "OKP",
45+
"x": "SEqAXr_hDfmdqLqepK--97NMkVlYF_A1ByPa2xycou8"
46+
}
47+
]
48+
},
49+
"alg": "EdDSA",
50+
"kid": "node-emoji-keys-EdDSA",
51+
"generator": "node"
52+
}

tests/fixtures/cross-lang/py-emoji-keys.json

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,25 +19,26 @@
1919
"signing_keys": [
2020
{
2121
"crv": "Ed25519",
22-
"x": "CX-4oqEqpxhUtsTrsaF2df7KBeIR0Wpe4bgwnlsMk8A",
22+
"x": "xrTm5ZIZUbFC1_S2Yw5KZkf-9m8--CmwP6-bkttx-ik",
2323
"kid": "py-emoji-keys-EdDSA",
2424
"alg": "EdDSA",
2525
"use": "sig",
2626
"kty": "OKP"
2727
}
2828
],
2929
"extras": {
30-
"豈": 1,
31-
"🍷": 2,
32-
"a": 3
30+
"a": 1,
31+
"豈": 2,
32+
"": 3,
33+
"🍷": 4
3334
},
34-
"signature": "eyJhbGciOiJFZERTQSIsImtpZCI6InB5LWVtb2ppLWtleXMtRWREU0EiLCJ0eXAiOiJ1Y3AtcHJvZmlsZStqd3MifQ.eyJjYXBhYmlsaXRpZXMiOltdLCJleHRyYXMiOnsiYSI6Mywi6LGIIjoxLCLwn423IjoyfSwibmFtZSI6IkVtb2ppIEtleXMgTWVyY2hhbnQiLCJwYXltZW50X2hhbmRsZXJzIjpbeyJjb25maWciOnt9LCJuYW1lIjoidGVtcG8ifV0sInNlcnZpY2VzIjpbeyJ0eXBlIjoicmVzdCIsInVybCI6Imh0dHBzOi8vZW1vamkuZXhhbXBsZS5jb20ifV0sInNpZ25pbmdfa2V5cyI6W3siYWxnIjoiRWREU0EiLCJjcnYiOiJFZDI1NTE5Iiwia2lkIjoicHktZW1vamkta2V5cy1FZERTQSIsImt0eSI6Ik9LUCIsInVzZSI6InNpZyIsIngiOiJDWC00b3FFcXB4aFV0c1Ryc2FGMmRmN0tCZUlSMFdwZTRiZ3dubHNNazhBIn1dLCJzcGVjIjoiaHR0cHM6Ly91Y3AuZGV2LyIsInZlcnNpb24iOiIyMDI2LTA0LTE3In0.aXPJHy4hcLxAF1zd9zLSZbbSMBP56BTeZVXY3V_Ywv4sqabLWgJGRmmp2iyJNamCFgYJ8jPIfd9nF1UU2R9WBg"
35+
"signature": "eyJhbGciOiJFZERTQSIsImtpZCI6InB5LWVtb2ppLWtleXMtRWREU0EiLCJ0eXAiOiJ1Y3AtcHJvZmlsZStqd3MifQ.eyJjYXBhYmlsaXRpZXMiOltdLCJleHRyYXMiOnsiYSI6MSwi6LGIIjoyLCLugIAiOjMsIvCfjbciOjR9LCJuYW1lIjoiRW1vamkgS2V5cyBNZXJjaGFudCIsInBheW1lbnRfaGFuZGxlcnMiOlt7ImNvbmZpZyI6e30sIm5hbWUiOiJ0ZW1wbyJ9XSwic2VydmljZXMiOlt7InR5cGUiOiJyZXN0IiwidXJsIjoiaHR0cHM6Ly9lbW9qaS5leGFtcGxlLmNvbSJ9XSwic2lnbmluZ19rZXlzIjpbeyJhbGciOiJFZERTQSIsImNydiI6IkVkMjU1MTkiLCJraWQiOiJweS1lbW9qaS1rZXlzLUVkRFNBIiwia3R5IjoiT0tQIiwidXNlIjoic2lnIiwieCI6InhyVG01WklaVWJGQzFfUzJZdzVLWmtmLTltOC0tQ213UDYtYmt0dHgtaWsifV0sInNwZWMiOiJodHRwczovL3VjcC5kZXYvIiwidmVyc2lvbiI6IjIwMjYtMDQtMTcifQ.O2ENDO4OJreRSvRZqbyMzbQlaG3SKy_zsfMFqqV6HUkwvIzmpH2bot_XtJzyz23RTsBdwvZtLxQJOSnBFkIfBQ"
3536
},
3637
"jwks": {
3738
"keys": [
3839
{
3940
"crv": "Ed25519",
40-
"x": "CX-4oqEqpxhUtsTrsaF2df7KBeIR0Wpe4bgwnlsMk8A",
41+
"x": "xrTm5ZIZUbFC1_S2Yw5KZkf-9m8--CmwP6-bkttx-ik",
4142
"kid": "py-emoji-keys-EdDSA",
4243
"alg": "EdDSA",
4344
"use": "sig",

tests/test_ucp.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Tests for build_ucp_profile."""
22

3+
import pytest
4+
35
from agentscore_commerce.identity import (
46
AGENTSCORE_UCP_CAPABILITY,
57
AssessResult,
@@ -109,3 +111,25 @@ def test_respects_agentscore_schema_url_override():
109111
)
110112
cap = next(c for c in profile.capabilities if c.name == AGENTSCORE_UCP_CAPABILITY)
111113
assert cap.schema == "https://custom.example/schema.json"
114+
115+
116+
@pytest.mark.parametrize(
117+
"key",
118+
[
119+
"version",
120+
"spec",
121+
"services",
122+
"capabilities",
123+
"payment_handlers",
124+
"signing_keys",
125+
"name",
126+
"signature",
127+
"__class__",
128+
"__dict__",
129+
"__init__",
130+
],
131+
)
132+
def test_extras_reserved_collision_rejected(key: str) -> None:
133+
profile = build_ucp_profile(**_base_kwargs(), extras={key: "attacker"})
134+
with pytest.raises(ValueError, match="collides with a reserved profile field"):
135+
profile.to_dict()

tests/test_ucp_cross_lang.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,20 @@ def test_corpus_covers_canonical_scenarios() -> None:
3030
generators = {json.loads(p.read_text())["generator"] for p in FIXTURES}
3131
assert "node" in generators
3232
assert "python" in generators
33-
# Each language ships 6 base scenarios so cross-lang verify exercises all of them.
33+
# `emoji-keys` exercises non-ASCII object keys with codepoints that genuinely
34+
# distinguish UTF-16 first-unit sort from Unicode codepoint sort: BMP private use
35+
# (U+E000) ranks BEFORE supplementary plane (U+1F377) by codepoint but AFTER it by
36+
# UTF-16 first unit (because the high surrogate 55356 < 57344). Both repos ship the
37+
# node and python emoji-keys fixtures so a regression in either language's key sort
38+
# surfaces here.
3439
for lang in ("node", "py"):
35-
for scenario in ("minimal", "es256-rails", "extras-int", "capability", "unicode", "multikey"):
40+
for scenario in (
41+
"minimal",
42+
"es256-rails",
43+
"extras-int",
44+
"capability",
45+
"unicode",
46+
"multikey",
47+
"emoji-keys",
48+
):
3649
assert f"{lang}-{scenario}.json" in names, f"missing fixture {lang}-{scenario}.json"
37-
# `py-emoji-keys.json` locks codepoint-aware key sort: Python sorts by Unicode
38-
# codepoint by default, JS default sort orders by UTF-16 code units which
39-
# diverges for supplementary-plane chars. The signed body covers BMP CJK
40-
# Compatibility (U+8C48), non-BMP wine glass (U+1F377), and ASCII so both
41-
# languages must explicitly sort by codepoint to maintain byte parity.
42-
assert "py-emoji-keys.json" in names

tests/test_ucp_jwks.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,18 @@ def test_accepts_int_and_string(self) -> None:
313313
signed = sign_ucp_profile(profile, signing_key=signer.private_key, kid="k")
314314
assert verify_ucp_profile(signed, build_jwks_response([signer.public_jwk])) is True
315315

316+
def test_rejects_float_in_set(self) -> None:
317+
signer = generate_ucp_signing_key(kid="k")
318+
profile = {**_base_profile([signer.public_jwk]), "extras": {"vals": {0.5}}}
319+
with pytest.raises(ValueError, match="rejects float"):
320+
sign_ucp_profile(profile, signing_key=signer.private_key, kid="k")
321+
322+
def test_rejects_float_in_frozenset(self) -> None:
323+
signer = generate_ucp_signing_key(kid="k")
324+
profile = {**_base_profile([signer.public_jwk]), "extras": {"vals": frozenset({0.25})}}
325+
with pytest.raises(ValueError, match="rejects float"):
326+
sign_ucp_profile(profile, signing_key=signer.private_key, kid="k")
327+
316328

317329
class TestUCPSigningKeyFromJWK:
318330
def test_round_trip_eddsa(self) -> None:

0 commit comments

Comments
 (0)