Skip to content

Commit 63a7ee2

Browse files
vvillait88claude
andcommitted
hardening(identity): typed-empty wins over raw + preserve empty payment_handler config
Three cross-language parity fixes for build_ucp_profile / UCPPaymentHandler: 1. `data.account_verification == {}` (and `data.operator_verification == {}`) means "API returned the block with no populated values" and now wins over the `data.raw` fallback. The previous `if not account_verification:` check treated empty-dict as falsy and bled raw values through. Mirrors the Node sibling, which reads the typed field directly without consulting raw. 2. `UCPPaymentHandler.to_dict()` always emits `config` (even when empty). TypeScript serializes `{name: 'tempo', config: {}}` with `config` preserved; the dataclass default is `field(default_factory=dict)` so the field is always a dict. The previous `if self.config:` truthy gate produced byte divergence on explicit `config={}` callers. 3. New `typed-claims` cross-lang fixture exercises the typed-field-only read path (`AssessResult(account_verification={...}, raw=None)`) that the existing `data-driven-claims` fixture didn't cover (it uses `raw=`). Both languages must produce byte-identical canonical bytes for the typed path or cross-lang verify silently drifts in production. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c00d51b commit 63a7ee2

6 files changed

Lines changed: 291 additions & 26 deletions

File tree

agentscore_commerce/identity/ucp.py

Lines changed: 28 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -165,10 +165,12 @@ class UCPPaymentHandler:
165165
config: dict[str, Any] = field(default_factory=dict)
166166

167167
def to_dict(self) -> dict[str, Any]:
168-
out: dict[str, Any] = {"name": self.name}
169-
if self.config:
170-
out["config"] = self.config
171-
return out
168+
# Always emit `config` (even when empty) so a Python-built handler matches
169+
# the Node sibling byte-for-byte: TypeScript serializes
170+
# `{name: 'tempo', config: {}}` with `config` preserved, and the dataclass
171+
# default is `field(default_factory=dict)` so the field is always a dict.
172+
# Cross-language verify drifts otherwise on explicit `config={}` callers.
173+
return {"name": self.name, "config": self.config}
172174

173175

174176
@dataclass
@@ -273,35 +275,39 @@ async def ucp_profile():
273275

274276
if data is not None and data.resolved_operator:
275277
# Match node-commerce read order: prefer the typed AssessResult fields,
276-
# fall back to ``data.raw`` only when the typed field is missing. The
277-
# Node sibling reads ``input.data.operator_verification`` /
278-
# ``input.data.account_verification`` directly without consulting
279-
# ``raw``; if a caller hand-constructs an AssessResult with mismatched
280-
# typed and raw verification blocks, both languages must pick the same
281-
# source so a profile signed in one verifies in the other.
278+
# fall back to ``data.raw`` only when the typed field is ``None`` (absent).
279+
# An explicitly-empty typed dict means "API returned the block with no
280+
# populated values" and wins over raw — same as the Node sibling, which
281+
# reads ``input.data.operator_verification`` / ``input.data.account_verification``
282+
# directly without consulting ``raw``. ``is None`` (not truthy) is the
283+
# correct distinction so a caller hand-constructing
284+
# ``AssessResult(account_verification={}, raw={"account_verification": {...}})``
285+
# gets the same empty-block behavior in both languages.
282286
typed_op = data.operator_verification
283-
operator_verification: dict[str, Any] = {}
284-
if isinstance(typed_op, dict):
287+
operator_verification: dict[str, Any]
288+
if typed_op is None:
289+
raw = data.raw or {}
290+
raw_op = raw.get("operator_verification") if isinstance(raw, dict) else None
291+
operator_verification = raw_op if isinstance(raw_op, dict) else {}
292+
elif isinstance(typed_op, dict):
285293
operator_verification = cast("dict[str, Any]", typed_op)
286-
elif typed_op is not None:
294+
else:
287295
# Convert OperatorVerification dataclass to a plain dict.
288296
operator_verification = {
289297
"level": getattr(typed_op, "level", None),
290298
"operator_type": getattr(typed_op, "operator_type", None),
291299
"verified_at": getattr(typed_op, "verified_at", None),
292300
}
293-
if not operator_verification:
294-
raw = data.raw or {}
295-
raw_op = raw.get("operator_verification") if isinstance(raw, dict) else None
296-
if isinstance(raw_op, dict):
297-
operator_verification = raw_op
298301

299-
account_verification: dict[str, Any] = data.account_verification or {}
300-
if not account_verification:
302+
account_verification: dict[str, Any]
303+
if data.account_verification is None:
301304
raw = data.raw or {}
302305
raw_av = raw.get("account_verification") if isinstance(raw, dict) else None
303-
if isinstance(raw_av, dict):
304-
account_verification = raw_av
306+
account_verification = raw_av if isinstance(raw_av, dict) else {}
307+
elif isinstance(data.account_verification, dict):
308+
account_verification = data.account_verification
309+
else:
310+
account_verification = {}
305311
# `dict.get(k) or DEFAULT` (not `dict.get(k, DEFAULT)`) coerces both a
306312
# missing key AND a present-but-falsy (None / "") value to the default,
307313
# matching the node sibling's `||` semantics. The API can return
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""One-shot generator for the typed-claims cross-lang fixture (Python side).
2+
3+
Writes ``tests/fixtures/cross-lang/py-typed-claims.json``. Sibling to
4+
``generate_data_driven_claims_fixture.py`` but exercises the **typed**
5+
``AssessResult.account_verification`` / ``AssessResult.operator_verification``
6+
read path (with ``raw=None``) instead of the raw-dict fallback. This catches
7+
drift in typed-field-only callers — production code populates both, but a
8+
hand-constructed AssessResult with only typed fields must produce a profile
9+
that the Node sibling verifies byte-for-byte, since Node's
10+
``buildUCPProfile`` reads the typed fields directly without ever consulting
11+
``raw``.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import json
17+
from pathlib import Path
18+
19+
from agentscore_commerce.identity import (
20+
AssessResult,
21+
OperatorVerification,
22+
UCPService,
23+
UCPSigningKey,
24+
build_ucp_profile,
25+
)
26+
from agentscore_commerce.identity.ucp_jwks import (
27+
build_jwks_response,
28+
generate_ucp_signing_key,
29+
sign_ucp_profile,
30+
)
31+
32+
OUT = Path(__file__).resolve().parent.parent / "tests" / "fixtures" / "cross-lang" / "py-typed-claims.json"
33+
34+
KID = "py-typed-claims-EdDSA"
35+
36+
37+
def main() -> None:
38+
key = generate_ucp_signing_key(kid=KID)
39+
40+
result = AssessResult(
41+
allow=True,
42+
resolved_operator="op_typed_claims",
43+
verify_url="https://agentscore.sh/verify/op_typed_claims",
44+
operator_verification=OperatorVerification(
45+
level="enhanced",
46+
operator_type="api",
47+
verified_at="2026-04-01T00:00:00Z",
48+
),
49+
account_verification={
50+
"kyc_level": "enhanced",
51+
"sanctions_clear": True,
52+
"age_bracket": "21+",
53+
"jurisdiction": "US",
54+
"verified_at": "2026-04-01T00:00:00Z",
55+
},
56+
raw=None,
57+
)
58+
59+
profile = build_ucp_profile(
60+
name="Typed Claims Merchant",
61+
services=[UCPService(type="rest", url="https://t.example.com")],
62+
payment_handlers=[],
63+
signing_keys=[UCPSigningKey.from_jwk(key.public_jwk)],
64+
data=result,
65+
)
66+
67+
signed = sign_ucp_profile(profile.to_dict(), signing_key=key.private_key, kid=KID)
68+
69+
fixture = {
70+
"profile": signed,
71+
"jwks": build_jwks_response([key.public_jwk]),
72+
"alg": "EdDSA",
73+
"kid": KID,
74+
"generator": "python",
75+
}
76+
77+
OUT.write_text(json.dumps(fixture, indent=2) + "\n")
78+
print(f"wrote {OUT}")
79+
80+
81+
if __name__ == "__main__":
82+
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://t.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_typed_claims",
18+
"kyc_level": "enhanced",
19+
"sanctions_clear": true,
20+
"age_bracket": "21+",
21+
"jurisdiction": "US",
22+
"verified_at": "2026-04-01T00:00:00Z",
23+
"verify_url": "https://agentscore.sh/verify/op_typed_claims",
24+
"issuer": "https://agentscore.sh"
25+
}
26+
}
27+
],
28+
"payment_handlers": [],
29+
"signing_keys": [
30+
{
31+
"kid": "node-typed-claims-EdDSA",
32+
"alg": "EdDSA",
33+
"use": "sig",
34+
"crv": "Ed25519",
35+
"kty": "OKP",
36+
"x": "hkhmYJSOPyC7tC2baujBsjvTdDs0M2gnmiTGEm_H9y0"
37+
}
38+
],
39+
"name": "Typed Claims Merchant",
40+
"signature": "eyJhbGciOiJFZERTQSIsImtpZCI6Im5vZGUtdHlwZWQtY2xhaW1zLUVkRFNBIiwidHlwIjoidWNwLXByb2ZpbGUrandzIn0.eyJjYXBhYmlsaXRpZXMiOlt7ImNsYWltcyI6eyJhZ2VfYnJhY2tldCI6IjIxKyIsImlzc3VlciI6Imh0dHBzOi8vYWdlbnRzY29yZS5zaCIsImp1cmlzZGljdGlvbiI6IlVTIiwia3ljX2xldmVsIjoiZW5oYW5jZWQiLCJvcGVyYXRvcl9pZCI6Im9wX3R5cGVkX2NsYWltcyIsInNhbmN0aW9uc19jbGVhciI6dHJ1ZSwidmVyaWZpZWRfYXQiOiIyMDI2LTA0LTAxVDAwOjAwOjAwWiIsInZlcmlmeV91cmwiOiJodHRwczovL2FnZW50c2NvcmUuc2gvdmVyaWZ5L29wX3R5cGVkX2NsYWltcyJ9LCJuYW1lIjoiYWdlbnRzY29yZS1pZGVudGl0eSIsInNjaGVtYSI6Imh0dHBzOi8vYWdlbnRzY29yZS5zaC9zY2hlbWFzL3VjcC9hZ2VudHNjb3JlLWlkZW50aXR5LnYxLmpzb24iLCJ2ZXJzaW9uIjoiMSJ9XSwibmFtZSI6IlR5cGVkIENsYWltcyBNZXJjaGFudCIsInBheW1lbnRfaGFuZGxlcnMiOltdLCJzZXJ2aWNlcyI6W3sidHlwZSI6InJlc3QiLCJ1cmwiOiJodHRwczovL3QuZXhhbXBsZS5jb20ifV0sInNpZ25pbmdfa2V5cyI6W3siYWxnIjoiRWREU0EiLCJjcnYiOiJFZDI1NTE5Iiwia2lkIjoibm9kZS10eXBlZC1jbGFpbXMtRWREU0EiLCJrdHkiOiJPS1AiLCJ1c2UiOiJzaWciLCJ4IjoiaGtobVlKU09QeUM3dEMyYmF1akJzanZUZERzME0yZ25taVRHRW1fSDl5MCJ9XSwic3BlYyI6Imh0dHBzOi8vdWNwLmRldi8iLCJ2ZXJzaW9uIjoiMjAyNi0wNC0xNyJ9.GJZcFBMvdIPmELSrUGzu--PmKwjItbpV74peSvcJcXRk6DRHgivYZOaTOPjFgZgOqnvhAEeG-gvy4O6jP5NrCA"
41+
},
42+
"jwks": {
43+
"keys": [
44+
{
45+
"kid": "node-typed-claims-EdDSA",
46+
"alg": "EdDSA",
47+
"use": "sig",
48+
"crv": "Ed25519",
49+
"kty": "OKP",
50+
"x": "hkhmYJSOPyC7tC2baujBsjvTdDs0M2gnmiTGEm_H9y0"
51+
}
52+
]
53+
},
54+
"alg": "EdDSA",
55+
"kid": "node-typed-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://t.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_typed_claims",
18+
"kyc_level": "enhanced",
19+
"sanctions_clear": true,
20+
"age_bracket": "21+",
21+
"jurisdiction": "US",
22+
"verified_at": "2026-04-01T00:00:00Z",
23+
"verify_url": "https://agentscore.sh/verify/op_typed_claims",
24+
"issuer": "https://agentscore.sh"
25+
}
26+
}
27+
],
28+
"payment_handlers": [],
29+
"signing_keys": [
30+
{
31+
"kid": "py-typed-claims-EdDSA",
32+
"kty": "OKP",
33+
"alg": "EdDSA",
34+
"use": "sig",
35+
"crv": "Ed25519",
36+
"x": "Qu9H2p75WjLc0DCdYY7MTaTkDZ0YPBFKHH3jsZMjFiA"
37+
}
38+
],
39+
"name": "Typed Claims Merchant",
40+
"signature": "eyJhbGciOiJFZERTQSIsImtpZCI6InB5LXR5cGVkLWNsYWltcy1FZERTQSIsInR5cCI6InVjcC1wcm9maWxlK2p3cyJ9.eyJjYXBhYmlsaXRpZXMiOlt7ImNsYWltcyI6eyJhZ2VfYnJhY2tldCI6IjIxKyIsImlzc3VlciI6Imh0dHBzOi8vYWdlbnRzY29yZS5zaCIsImp1cmlzZGljdGlvbiI6IlVTIiwia3ljX2xldmVsIjoiZW5oYW5jZWQiLCJvcGVyYXRvcl9pZCI6Im9wX3R5cGVkX2NsYWltcyIsInNhbmN0aW9uc19jbGVhciI6dHJ1ZSwidmVyaWZpZWRfYXQiOiIyMDI2LTA0LTAxVDAwOjAwOjAwWiIsInZlcmlmeV91cmwiOiJodHRwczovL2FnZW50c2NvcmUuc2gvdmVyaWZ5L29wX3R5cGVkX2NsYWltcyJ9LCJuYW1lIjoiYWdlbnRzY29yZS1pZGVudGl0eSIsInNjaGVtYSI6Imh0dHBzOi8vYWdlbnRzY29yZS5zaC9zY2hlbWFzL3VjcC9hZ2VudHNjb3JlLWlkZW50aXR5LnYxLmpzb24iLCJ2ZXJzaW9uIjoiMSJ9XSwibmFtZSI6IlR5cGVkIENsYWltcyBNZXJjaGFudCIsInBheW1lbnRfaGFuZGxlcnMiOltdLCJzZXJ2aWNlcyI6W3sidHlwZSI6InJlc3QiLCJ1cmwiOiJodHRwczovL3QuZXhhbXBsZS5jb20ifV0sInNpZ25pbmdfa2V5cyI6W3siYWxnIjoiRWREU0EiLCJjcnYiOiJFZDI1NTE5Iiwia2lkIjoicHktdHlwZWQtY2xhaW1zLUVkRFNBIiwia3R5IjoiT0tQIiwidXNlIjoic2lnIiwieCI6IlF1OUgycDc1V2pMYzBEQ2RZWTdNVGFUa0RaMFlQQkZLSEgzanNaTWpGaUEifV0sInNwZWMiOiJodHRwczovL3VjcC5kZXYvIiwidmVyc2lvbiI6IjIwMjYtMDQtMTcifQ.Awkp_QIMwjiiBE4CSiZQBkxXNdxwGBIPW36sAFIngbax_otu5N5S2kBlnt4xUhvRCJ-_CHieGCPJseIXa0i9Dg"
41+
},
42+
"jwks": {
43+
"keys": [
44+
{
45+
"crv": "Ed25519",
46+
"x": "Qu9H2p75WjLc0DCdYY7MTaTkDZ0YPBFKHH3jsZMjFiA",
47+
"kid": "py-typed-claims-EdDSA",
48+
"alg": "EdDSA",
49+
"use": "sig",
50+
"kty": "OKP"
51+
}
52+
]
53+
},
54+
"alg": "EdDSA",
55+
"kid": "py-typed-claims-EdDSA",
56+
"generator": "python"
57+
}

tests/test_ucp.py

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

3+
from typing import Any, cast
4+
35
import pytest
46

57
from agentscore_commerce.identity import (
@@ -335,3 +337,59 @@ def test_ucp_signing_key_extras_non_reserved_pass_through() -> None:
335337
sk = UCPSigningKey(kid="me", kty="EC", alg="ES256", crv="P-256", extras={"x": "abc", "y": "def"})
336338
out = sk.to_dict()
337339
assert out == {"kid": "me", "kty": "EC", "alg": "ES256", "crv": "P-256", "x": "abc", "y": "def"}
340+
341+
342+
# UCPPaymentHandler.to_dict always emits `config`. The Node sibling serializes
343+
# `{name: 'tempo', config: {}}` with `config` preserved (TypeScript optional
344+
# field initialized to a new object). Cross-language byte-parity requires the
345+
# Python emitter to do the same — even when the dataclass default
346+
# `field(default_factory=dict)` left config empty.
347+
348+
349+
def test_ucp_payment_handler_to_dict_preserves_empty_config() -> None:
350+
assert UCPPaymentHandler(name="tempo").to_dict() == {"name": "tempo", "config": {}}
351+
352+
353+
def test_ucp_payment_handler_to_dict_preserves_explicit_empty_config() -> None:
354+
assert UCPPaymentHandler(name="tempo", config={}).to_dict() == {"name": "tempo", "config": {}}
355+
356+
357+
def test_ucp_payment_handler_to_dict_preserves_populated_config() -> None:
358+
assert UCPPaymentHandler(name="tempo", config={"recipient": "0xabc"}).to_dict() == {
359+
"name": "tempo",
360+
"config": {"recipient": "0xabc"},
361+
}
362+
363+
364+
# Typed-vs-raw read order: `data.account_verification == {}` means "API
365+
# explicitly returned an empty block" and must win over `data.raw`. Only when
366+
# the typed field is `None` does the builder fall back to raw. Mirrors the Node
367+
# sibling, which reads the typed field directly without consulting raw.
368+
369+
370+
def test_typed_empty_account_verification_wins_over_raw() -> None:
371+
result = AssessResult(
372+
allow=True,
373+
resolved_operator="op_xyz",
374+
account_verification={},
375+
raw={"account_verification": {"kyc_level": "verified"}},
376+
)
377+
profile = build_ucp_profile(**_base_kwargs(), data=result)
378+
cap = next(c for c in profile.capabilities if c.name == AGENTSCORE_UCP_CAPABILITY)
379+
# Empty typed dict suppresses the raw fallback; kyc_level falls through to
380+
# the schema default "none" instead of bleeding the raw "verified" value.
381+
assert cap.extras["claims"]["kyc_level"] == "none"
382+
383+
384+
def test_typed_empty_operator_verification_wins_over_raw() -> None:
385+
result = AssessResult(
386+
allow=True,
387+
resolved_operator="op_xyz",
388+
# Empty dict is a valid typed value (means "operator block returned empty").
389+
operator_verification=cast("Any", {}),
390+
raw={"operator_verification": {"level": "enhanced", "verified_at": "2026-01-01T00:00:00Z"}},
391+
)
392+
profile = build_ucp_profile(**_base_kwargs(), data=result)
393+
cap = next(c for c in profile.capabilities if c.name == AGENTSCORE_UCP_CAPABILITY)
394+
# Empty typed dict suppresses raw fallback; verified_at falls through to None.
395+
assert cap.extras["claims"]["verified_at"] is None

tests/test_ucp_cross_lang.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,15 @@ 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.
49+
# `data-driven-claims` exercises the raw-dict fallback read path
50+
# (`AssessResult(raw={"account_verification": {...}})`) that
51+
# production callers populate. `typed-claims` exercises the typed
52+
# field path (`AssessResult(account_verification={...}, raw=None)`)
53+
# that hand-constructed callers use — Node's `buildUCPProfile`
54+
# reads typed fields directly without consulting raw, so both
55+
# paths must produce byte-identical canonical bytes across
56+
# languages or cross-lang verify silently drifts.
5357
"data-driven-claims",
58+
"typed-claims",
5459
):
5560
assert f"{lang}-{scenario}.json" in names, f"missing fixture {lang}-{scenario}.json"

0 commit comments

Comments
 (0)