Skip to content

Commit acda956

Browse files
vvillait88claude
andcommitted
hardening(identity): align build_ucp_profile claims coalescing with node sibling
The API can return account_verification with either null or empty-string for un-set fields depending on the row state. The python builder used dict.get(k, DEFAULT) for age_bracket/jurisdiction, which returns None verbatim when the key is present-but-null instead of falling through to the default. The node sibling used `??` (collapse null/undefined only, pass empty-string verbatim). For the same AssessResult shape, that meant the two SDKs emitted different canonical claims blocks, so a profile signed in one language failed verify in the other. Switch python to `dict.get(k) or DEFAULT` for age_bracket and jurisdiction so null AND empty-string both fall through to the schema default. The kyc_level and verified_at branches already used `or` semantics; verified and left as-is. Node sibling switched to `||` in the same round. Adds a data-driven-claims cross-lang fixture that, unlike the rest of the corpus, exercises build_ucp_profile's actual data path (constructs a synthetic AssessResult with the API "missing" sentinels and lets the builder coalesce). Both languages now emit identical canonical bytes for the input. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7fe7f32 commit acda956

6 files changed

Lines changed: 253 additions & 2 deletions

File tree

agentscore_commerce/identity/ucp.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -261,12 +261,18 @@ async def ucp_profile():
261261
operator_verification = {}
262262
if not isinstance(account_verification, dict):
263263
account_verification = {}
264+
# `dict.get(k) or DEFAULT` (not `dict.get(k, DEFAULT)`) coerces both a
265+
# missing key AND a present-but-falsy (None / "") value to the default,
266+
# matching the node sibling's `||` semantics. The API can return
267+
# `account_verification` with either null or `""` for un-set fields
268+
# depending on the row state, and a profile signed in one language must
269+
# verify in the other across both shapes.
264270
claims = {
265271
"operator_id": data.resolved_operator,
266272
"kyc_level": account_verification.get("kyc_level") or operator_verification.get("level") or "none",
267273
"sanctions_clear": account_verification.get("sanctions_clear") is True,
268-
"age_bracket": account_verification.get("age_bracket", "unknown"),
269-
"jurisdiction": account_verification.get("jurisdiction", ""),
274+
"age_bracket": account_verification.get("age_bracket") or "unknown",
275+
"jurisdiction": account_verification.get("jurisdiction") or "",
270276
"verified_at": account_verification.get("verified_at") or operator_verification.get("verified_at"),
271277
"verify_url": data.verify_url,
272278
"issuer": "https://agentscore.sh",
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""One-shot generator for the data-driven-claims cross-lang fixture (Python side).
2+
3+
Writes ``tests/fixtures/cross-lang/py-data-driven-claims.json``. Unlike the
4+
other cross-lang fixtures (which hand-craft the ``agentscore-identity``
5+
capability), this one EXERCISES ``build_ucp_profile``'s data path: it
6+
constructs a synthetic ``AssessResult`` with the API-shape "missing" sentinels
7+
(empty string for kyc_level, None for age_bracket / jurisdiction /
8+
verified_at) and lets the builder coalesce them. Both languages MUST emit
9+
identical canonical bytes for this input or cross-lang verify drifts silently
10+
in production.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import json
16+
from pathlib import Path
17+
18+
from agentscore_commerce.identity import (
19+
AssessResult,
20+
UCPService,
21+
UCPSigningKey,
22+
build_ucp_profile,
23+
)
24+
from agentscore_commerce.identity.ucp_jwks import (
25+
build_jwks_response,
26+
generate_ucp_signing_key,
27+
sign_ucp_profile,
28+
)
29+
30+
OUT = Path(__file__).resolve().parent.parent / "tests" / "fixtures" / "cross-lang" / "py-data-driven-claims.json"
31+
32+
KID = "py-data-driven-claims-EdDSA"
33+
34+
35+
def main() -> None:
36+
key = generate_ucp_signing_key(kid=KID)
37+
38+
result = AssessResult(
39+
allow=True,
40+
resolved_operator="op_data_driven",
41+
verify_url="https://agentscore.sh/verify/op_data_driven",
42+
raw={
43+
"account_verification": {
44+
# Empty string is the API's "set but unknown" shape for some
45+
# columns; None is the shape for others. The builder must
46+
# coerce both to the schema default identically across node
47+
# and python.
48+
"kyc_level": "",
49+
"sanctions_clear": False,
50+
"age_bracket": None,
51+
"jurisdiction": None,
52+
"verified_at": None,
53+
},
54+
},
55+
)
56+
57+
profile = build_ucp_profile(
58+
name="Data Driven Claims Merchant",
59+
services=[UCPService(type="rest", url="https://d.example.com")],
60+
payment_handlers=[],
61+
signing_keys=[UCPSigningKey.from_jwk(key.public_jwk)],
62+
data=result,
63+
)
64+
65+
signed = sign_ucp_profile(profile.to_dict(), signing_key=key.private_key, kid=KID)
66+
67+
fixture = {
68+
"profile": signed,
69+
"jwks": build_jwks_response([key.public_jwk]),
70+
"alg": "EdDSA",
71+
"kid": KID,
72+
"generator": "python",
73+
}
74+
75+
OUT.write_text(json.dumps(fixture, indent=2) + "\n")
76+
print(f"wrote {OUT}")
77+
78+
79+
if __name__ == "__main__":
80+
main()
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
{
2+
"profile": {
3+
"version": "2026-04-17",
4+
"spec": "https://ucp.dev/",
5+
"services": [
6+
{
7+
"type": "rest",
8+
"url": "https://d.example.com"
9+
}
10+
],
11+
"capabilities": [
12+
{
13+
"name": "agentscore-identity",
14+
"version": "1",
15+
"schema": "https://agentscore.sh/schemas/ucp/agentscore-identity.v1.json",
16+
"claims": {
17+
"operator_id": "op_data_driven",
18+
"kyc_level": "none",
19+
"sanctions_clear": false,
20+
"age_bracket": "unknown",
21+
"jurisdiction": "",
22+
"verified_at": null,
23+
"verify_url": "https://agentscore.sh/verify/op_data_driven",
24+
"issuer": "https://agentscore.sh"
25+
}
26+
}
27+
],
28+
"payment_handlers": [],
29+
"signing_keys": [
30+
{
31+
"kid": "node-data-driven-claims-EdDSA",
32+
"alg": "EdDSA",
33+
"use": "sig",
34+
"crv": "Ed25519",
35+
"kty": "OKP",
36+
"x": "1GQBzacuSLmz5l6LPHluSWLNI1xgcriiRdqs9sO22hY"
37+
}
38+
],
39+
"name": "Data Driven Claims Merchant",
40+
"signature": "eyJhbGciOiJFZERTQSIsImtpZCI6Im5vZGUtZGF0YS1kcml2ZW4tY2xhaW1zLUVkRFNBIiwidHlwIjoidWNwLXByb2ZpbGUrandzIn0.eyJjYXBhYmlsaXRpZXMiOlt7ImNsYWltcyI6eyJhZ2VfYnJhY2tldCI6InVua25vd24iLCJpc3N1ZXIiOiJodHRwczovL2FnZW50c2NvcmUuc2giLCJqdXJpc2RpY3Rpb24iOiIiLCJreWNfbGV2ZWwiOiJub25lIiwib3BlcmF0b3JfaWQiOiJvcF9kYXRhX2RyaXZlbiIsInNhbmN0aW9uc19jbGVhciI6ZmFsc2UsInZlcmlmaWVkX2F0IjpudWxsLCJ2ZXJpZnlfdXJsIjoiaHR0cHM6Ly9hZ2VudHNjb3JlLnNoL3ZlcmlmeS9vcF9kYXRhX2RyaXZlbiJ9LCJuYW1lIjoiYWdlbnRzY29yZS1pZGVudGl0eSIsInNjaGVtYSI6Imh0dHBzOi8vYWdlbnRzY29yZS5zaC9zY2hlbWFzL3VjcC9hZ2VudHNjb3JlLWlkZW50aXR5LnYxLmpzb24iLCJ2ZXJzaW9uIjoiMSJ9XSwibmFtZSI6IkRhdGEgRHJpdmVuIENsYWltcyBNZXJjaGFudCIsInBheW1lbnRfaGFuZGxlcnMiOltdLCJzZXJ2aWNlcyI6W3sidHlwZSI6InJlc3QiLCJ1cmwiOiJodHRwczovL2QuZXhhbXBsZS5jb20ifV0sInNpZ25pbmdfa2V5cyI6W3siYWxnIjoiRWREU0EiLCJjcnYiOiJFZDI1NTE5Iiwia2lkIjoibm9kZS1kYXRhLWRyaXZlbi1jbGFpbXMtRWREU0EiLCJrdHkiOiJPS1AiLCJ1c2UiOiJzaWciLCJ4IjoiMUdRQnphY3VTTG16NWw2TFBIbHVTV0xOSTF4Z2NyaWlSZHFzOXNPMjJoWSJ9XSwic3BlYyI6Imh0dHBzOi8vdWNwLmRldi8iLCJ2ZXJzaW9uIjoiMjAyNi0wNC0xNyJ9.yBVx0_My6D8OAF-g6866FiM24IChFrfQqE5IPhhoxHiNO8qjgBRlE0MCGhUdW0i-3mF8TroUsnsaVv0NV_vbDw"
41+
},
42+
"jwks": {
43+
"keys": [
44+
{
45+
"kid": "node-data-driven-claims-EdDSA",
46+
"alg": "EdDSA",
47+
"use": "sig",
48+
"crv": "Ed25519",
49+
"kty": "OKP",
50+
"x": "1GQBzacuSLmz5l6LPHluSWLNI1xgcriiRdqs9sO22hY"
51+
}
52+
]
53+
},
54+
"alg": "EdDSA",
55+
"kid": "node-data-driven-claims-EdDSA",
56+
"generator": "node"
57+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
{
2+
"profile": {
3+
"version": "2026-04-17",
4+
"spec": "https://ucp.dev/",
5+
"services": [
6+
{
7+
"type": "rest",
8+
"url": "https://d.example.com"
9+
}
10+
],
11+
"capabilities": [
12+
{
13+
"name": "agentscore-identity",
14+
"schema": "https://agentscore.sh/schemas/ucp/agentscore-identity.v1.json",
15+
"version": "1",
16+
"claims": {
17+
"operator_id": "op_data_driven",
18+
"kyc_level": "none",
19+
"sanctions_clear": false,
20+
"age_bracket": "unknown",
21+
"jurisdiction": "",
22+
"verified_at": null,
23+
"verify_url": "https://agentscore.sh/verify/op_data_driven",
24+
"issuer": "https://agentscore.sh"
25+
}
26+
}
27+
],
28+
"payment_handlers": [],
29+
"signing_keys": [
30+
{
31+
"kid": "py-data-driven-claims-EdDSA",
32+
"kty": "OKP",
33+
"alg": "EdDSA",
34+
"use": "sig",
35+
"crv": "Ed25519",
36+
"x": "e0tM2PG2SrWLVh2twzUQqc4wVi5isQJTWZLWe9Jceqg"
37+
}
38+
],
39+
"name": "Data Driven Claims Merchant",
40+
"signature": "eyJhbGciOiJFZERTQSIsImtpZCI6InB5LWRhdGEtZHJpdmVuLWNsYWltcy1FZERTQSIsInR5cCI6InVjcC1wcm9maWxlK2p3cyJ9.eyJjYXBhYmlsaXRpZXMiOlt7ImNsYWltcyI6eyJhZ2VfYnJhY2tldCI6InVua25vd24iLCJpc3N1ZXIiOiJodHRwczovL2FnZW50c2NvcmUuc2giLCJqdXJpc2RpY3Rpb24iOiIiLCJreWNfbGV2ZWwiOiJub25lIiwib3BlcmF0b3JfaWQiOiJvcF9kYXRhX2RyaXZlbiIsInNhbmN0aW9uc19jbGVhciI6ZmFsc2UsInZlcmlmaWVkX2F0IjpudWxsLCJ2ZXJpZnlfdXJsIjoiaHR0cHM6Ly9hZ2VudHNjb3JlLnNoL3ZlcmlmeS9vcF9kYXRhX2RyaXZlbiJ9LCJuYW1lIjoiYWdlbnRzY29yZS1pZGVudGl0eSIsInNjaGVtYSI6Imh0dHBzOi8vYWdlbnRzY29yZS5zaC9zY2hlbWFzL3VjcC9hZ2VudHNjb3JlLWlkZW50aXR5LnYxLmpzb24iLCJ2ZXJzaW9uIjoiMSJ9XSwibmFtZSI6IkRhdGEgRHJpdmVuIENsYWltcyBNZXJjaGFudCIsInBheW1lbnRfaGFuZGxlcnMiOltdLCJzZXJ2aWNlcyI6W3sidHlwZSI6InJlc3QiLCJ1cmwiOiJodHRwczovL2QuZXhhbXBsZS5jb20ifV0sInNpZ25pbmdfa2V5cyI6W3siYWxnIjoiRWREU0EiLCJjcnYiOiJFZDI1NTE5Iiwia2lkIjoicHktZGF0YS1kcml2ZW4tY2xhaW1zLUVkRFNBIiwia3R5IjoiT0tQIiwidXNlIjoic2lnIiwieCI6ImUwdE0yUEcyU3JXTFZoMnR3elVRcWM0d1ZpNWlzUUpUV1pMV2U5SmNlcWcifV0sInNwZWMiOiJodHRwczovL3VjcC5kZXYvIiwidmVyc2lvbiI6IjIwMjYtMDQtMTcifQ.IRSaAW3aI_uT0YBakBlQ_DalJNlvmiID89pmeK2avjS1rZ1FWTjTnYv4fHYbkolTYKYSW4PNC8rV4hTYtPOzDg"
41+
},
42+
"jwks": {
43+
"keys": [
44+
{
45+
"crv": "Ed25519",
46+
"x": "e0tM2PG2SrWLVh2twzUQqc4wVi5isQJTWZLWe9Jceqg",
47+
"kid": "py-data-driven-claims-EdDSA",
48+
"alg": "EdDSA",
49+
"use": "sig",
50+
"kty": "OKP"
51+
}
52+
]
53+
},
54+
"alg": "EdDSA",
55+
"kid": "py-data-driven-claims-EdDSA",
56+
"generator": "python"
57+
}

tests/test_ucp.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,49 @@ def test_extras_reserved_collision_rejected(key: str) -> None:
133133
profile = build_ucp_profile(**_base_kwargs(), extras={key: "attacker"})
134134
with pytest.raises(ValueError, match="collides with a reserved profile field"):
135135
profile.to_dict()
136+
137+
138+
# Empty-string and null normalization: the API can emit
139+
# ``account_verification`` with either null or ``""`` for un-set fields, and the
140+
# node + python siblings must produce the SAME canonical claims block for either
141+
# shape so a profile signed in one language verifies in the other.
142+
143+
144+
def _claims_of(account_verification: dict) -> dict:
145+
result = AssessResult(
146+
allow=True,
147+
resolved_operator="op_abc",
148+
raw={"account_verification": account_verification},
149+
)
150+
profile = build_ucp_profile(**_base_kwargs(), data=result)
151+
d = profile.to_dict()
152+
cap = next(c for c in d["capabilities"] if c["name"] == AGENTSCORE_UCP_CAPABILITY)
153+
return cap["claims"]
154+
155+
156+
def test_coerces_empty_string_kyc_level_to_none() -> None:
157+
assert _claims_of({"kyc_level": ""})["kyc_level"] == "none"
158+
159+
160+
def test_coerces_null_age_bracket_to_unknown() -> None:
161+
assert _claims_of({"age_bracket": None})["age_bracket"] == "unknown"
162+
163+
164+
def test_coerces_empty_string_age_bracket_to_unknown() -> None:
165+
assert _claims_of({"age_bracket": ""})["age_bracket"] == "unknown"
166+
167+
168+
def test_coerces_null_jurisdiction_to_empty_string() -> None:
169+
assert _claims_of({"jurisdiction": None})["jurisdiction"] == ""
170+
171+
172+
def test_coerces_empty_string_jurisdiction_to_empty_string() -> None:
173+
assert _claims_of({"jurisdiction": ""})["jurisdiction"] == ""
174+
175+
176+
def test_coerces_null_verified_at_to_none() -> None:
177+
assert _claims_of({"verified_at": None})["verified_at"] is None
178+
179+
180+
def test_coerces_empty_string_verified_at_to_none() -> None:
181+
assert _claims_of({"verified_at": ""})["verified_at"] is None

tests/test_ucp_cross_lang.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,5 +46,10 @@ def test_corpus_covers_canonical_scenarios() -> None:
4646
"multikey",
4747
"emoji-keys",
4848
"int-boundary",
49+
# `data-driven-claims` is the only fixture in the corpus that
50+
# exercises ``build_ucp_profile`` / ``buildUCPProfile``'s data path
51+
# (vs. hand-crafted capabilities). Catches drift in
52+
# ``account_verification`` coalescing.
53+
"data-driven-claims",
4954
):
5055
assert f"{lang}-{scenario}.json" in names, f"missing fixture {lang}-{scenario}.json"

0 commit comments

Comments
 (0)