Skip to content

Commit 4dfee92

Browse files
vvillait88claude
andcommitted
hardening(identity): align UCP read order to typed-first + reject bytes
Two cross-language parity fixes from round-29 SDK review: LOW-1: build_ucp_profile now reads operator_verification and account_verification from the typed AssessResult fields first, falling back to data.raw only when the typed field is missing. Previously raw won, diverging from node-commerce which reads typed fields directly. With raw and typed in disagreement, both languages now pick the same source so a profile signed by one verifies in the other. LOW-2: _reject_unsafe_numbers now rejects bytes / bytearray with a typed ValueError before json.dumps can raise its raw "Object of type bytes is not JSON serializable" TypeError. Mirrors the node sibling's "typed arrays are not allowed" rejection in stableStringify. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2f598ca commit 4dfee92

4 files changed

Lines changed: 94 additions & 28 deletions

File tree

agentscore_commerce/identity/ucp.py

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -272,28 +272,33 @@ async def ucp_profile():
272272
base_capabilities = list(capabilities or [])
273273

274274
if data is not None and data.resolved_operator:
275-
raw = data.raw or {}
276-
operator_verification = raw.get("operator_verification") if isinstance(raw, dict) else None
275+
# 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.
282+
typed_op = getattr(data, "operator_verification", None)
283+
if typed_op is not None and not isinstance(typed_op, dict):
284+
# Convert OperatorVerification dataclass to a plain dict.
285+
operator_verification = {
286+
"level": getattr(typed_op, "level", None),
287+
"operator_type": getattr(typed_op, "operator_type", None),
288+
"verified_at": getattr(typed_op, "verified_at", None),
289+
}
290+
else:
291+
operator_verification = typed_op
277292
if not operator_verification:
278-
# Fallback to the typed AssessResult.operator_verification field when
279-
# `raw` doesn't carry it. Mirrors the node sibling's typed-field read
280-
# path so a hand-constructed AssessResult (no `raw`) still surfaces
281-
# the operator verification block in the UCP capability claims.
282-
typed_op = getattr(data, "operator_verification", None)
283-
if typed_op is not None and not isinstance(typed_op, dict):
284-
# Convert OperatorVerification dataclass to a plain dict.
285-
operator_verification = {
286-
"level": getattr(typed_op, "level", None),
287-
"operator_type": getattr(typed_op, "operator_type", None),
288-
"verified_at": getattr(typed_op, "verified_at", None),
289-
}
290-
else:
291-
operator_verification = typed_op
292-
account_verification = raw.get("account_verification") if isinstance(raw, dict) else None
293-
if not account_verification:
294-
account_verification = getattr(data, "account_verification", None)
293+
raw = data.raw or {}
294+
operator_verification = raw.get("operator_verification") if isinstance(raw, dict) else None
295295
if not isinstance(operator_verification, dict):
296296
operator_verification = {}
297+
298+
account_verification = getattr(data, "account_verification", None)
299+
if not account_verification:
300+
raw = data.raw or {}
301+
account_verification = raw.get("account_verification") if isinstance(raw, dict) else None
297302
if not isinstance(account_verification, dict):
298303
account_verification = {}
299304
# `dict.get(k) or DEFAULT` (not `dict.get(k, DEFAULT)`) coerces both a

agentscore_commerce/identity/ucp_jwks.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,17 @@ def _reject_unsafe_numbers(value: Any) -> None:
222222
"Convert to a sorted list before passing."
223223
)
224224
raise ValueError(msg)
225+
# Reject bytes / bytearray with a typed message (mirrors the node sibling's
226+
# "typed arrays are not allowed" rejection in stableStringify). Without this,
227+
# raw bytes fall through cleanly and surface a confusing
228+
# `TypeError: Object of type bytes is not JSON serializable` from
229+
# `json.dumps` later. Convert to a base64url string before passing.
230+
if isinstance(value, bytes | bytearray):
231+
msg = (
232+
f"{type(value).__name__} values are not allowed in canonicalized JSON. "
233+
"Convert to a base64url string before passing."
234+
)
235+
raise ValueError(msg)
225236
if isinstance(value, dict):
226237
for k, v in value.items():
227238
_reject_unsafe_numbers(k)

tests/test_ucp.py

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -235,26 +235,51 @@ def test_typed_account_verification_fallback_via_setattr() -> None:
235235
assert claims["sanctions_clear"] is True
236236

237237

238-
def test_raw_takes_precedence_over_typed_fallback() -> None:
239-
# When raw carries `operator_verification`, the typed-field fallback is NOT
240-
# consulted. Production callers populate raw and the typed fields stay
241-
# in sync; this test pins the precedence so a typed mismatch can't silently
242-
# override the raw payload.
238+
def test_typed_takes_precedence_over_raw() -> None:
239+
# When the typed `operator_verification` / `account_verification` fields
240+
# disagree with `data.raw`, the typed values win. Mirrors the node sibling
241+
# which reads `input.data.operator_verification` directly without
242+
# consulting `raw`. Production callers populate raw and the typed fields
243+
# stay in sync; pinning typed-precedence keeps a hand-constructed
244+
# AssessResult from emitting a profile that one language verifies and the
245+
# other rejects.
243246
result = AssessResult(
244247
allow=True,
245248
resolved_operator="op_xyz",
246-
operator_verification=OperatorVerification(level="enhanced"),
249+
operator_verification=OperatorVerification(level="verified"),
247250
raw={
248-
"operator_verification": {"level": "verified"},
249-
"account_verification": {"kyc_level": "verified"},
251+
"operator_verification": {"level": "none"},
252+
"account_verification": {"kyc_level": "none"},
250253
},
251254
)
255+
result.account_verification = {"kyc_level": "verified"} # type: ignore[attr-defined]
252256
profile = build_ucp_profile(**_base_kwargs(), data=result)
253257
cap = next(c for c in profile.capabilities if c.name == AGENTSCORE_UCP_CAPABILITY)
254-
# `kyc_level` reads from raw account_verification.kyc_level first.
258+
# Typed `account_verification.kyc_level == 'verified'` wins over the
259+
# `none` value carried in `data.raw`.
255260
assert cap.extras["claims"]["kyc_level"] == "verified"
256261

257262

263+
def test_raw_fallback_used_when_typed_missing() -> None:
264+
# When typed `operator_verification` / `account_verification` are absent,
265+
# the builder falls back to `data.raw`. This is the production path:
266+
# `AgentScoreClient` populates both, but legacy or ad-hoc callers may
267+
# only set raw.
268+
result = AssessResult(
269+
allow=True,
270+
resolved_operator="op_raw",
271+
operator_verification=None,
272+
raw={
273+
"operator_verification": {"level": "enhanced"},
274+
"account_verification": {"kyc_level": "enhanced"},
275+
},
276+
)
277+
profile = build_ucp_profile(**_base_kwargs(), data=result)
278+
cap = next(c for c in profile.capabilities if c.name == AGENTSCORE_UCP_CAPABILITY)
279+
# `kyc_level` falls back to raw `account_verification.kyc_level`.
280+
assert cap.extras["claims"]["kyc_level"] == "enhanced"
281+
282+
258283
# Per-element to_dict reserved-key collision guard. Mirrors the parent
259284
# UCPProfile.to_dict guard so vendor extras can't silently overwrite a canonical
260285
# field on UCPService / UCPCapability / UCPSigningKey via `out.update(extras)`.

tests/test_ucp_jwks.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,31 @@ def test_rejects_set_of_valid_strings_with_typed_message(self) -> None:
344344
with pytest.raises(ValueError, match="set values are not allowed"):
345345
sign_ucp_profile(profile, signing_key=signer.private_key, kid="k")
346346

347+
def test_rejects_bytes_values_outright(self) -> None:
348+
# `bytes` is not representable in JSON; the canonicalizer rejects it with a
349+
# typed message before `json.dumps` can raise its raw
350+
# `TypeError: Object of type bytes is not JSON serializable`. Mirrors
351+
# node's `stableStringify: typed arrays are not allowed`.
352+
signer = generate_ucp_signing_key(kid="k")
353+
profile = {**_base_profile([signer.public_jwk]), "extras": {"blob": b"hello"}}
354+
with pytest.raises(ValueError, match="bytes values are not allowed"):
355+
sign_ucp_profile(profile, signing_key=signer.private_key, kid="k")
356+
357+
def test_rejects_bytearray_values_outright(self) -> None:
358+
signer = generate_ucp_signing_key(kid="k")
359+
profile = {**_base_profile([signer.public_jwk]), "extras": {"blob": bytearray(b"hello")}}
360+
with pytest.raises(ValueError, match="bytearray values are not allowed"):
361+
sign_ucp_profile(profile, signing_key=signer.private_key, kid="k")
362+
363+
def test_rejects_empty_bytes_with_typed_message(self) -> None:
364+
# Empty bytes would fall through `_reject_unsafe_numbers` cleanly and
365+
# surface a raw `TypeError` from `json.dumps` later. The typed reject
366+
# ensures callers get a guiding ValueError instead.
367+
signer = generate_ucp_signing_key(kid="k")
368+
profile = {**_base_profile([signer.public_jwk]), "extras": {"blob": b""}}
369+
with pytest.raises(ValueError, match="bytes values are not allowed"):
370+
sign_ucp_profile(profile, signing_key=signer.private_key, kid="k")
371+
347372
def test_accepts_max_safe_int_boundary(self) -> None:
348373
signer = generate_ucp_signing_key(kid="k")
349374
profile = {**_base_profile([signer.public_jwk]), "extras": {"big": 2**53 - 1}}

0 commit comments

Comments
 (0)