Skip to content

Commit 48bced1

Browse files
vvillait88claude
andcommitted
hardening(identity): typed-field fallback + per-element extras guard + set/frozenset reject
Three reviewer LOW findings on the UCP profile path: * `build_ucp_profile` now falls back to the typed `AssessResult.operator_verification` (and ad-hoc `account_verification`) field when `data.raw` doesn't carry the block. Production callers populate `raw`, but a hand-constructed `AssessResult(raw=None)` was silently dropping the operator verification block, diverging from the node sibling's typed-field read path. * `UCPService`, `UCPCapability`, and `UCPSigningKey` now mirror `UCPProfile.to_dict`'s reserved-key collision guard so vendor `extras` can't silently overwrite a canonical declared field via `out.update(extras)`. `UCPPaymentHandler` has no `extras` attribute and is unaffected. * `_reject_unsafe_numbers` now rejects `set` / `frozenset` outright with a typed `ValueError` (mirroring node's `stableStringify: Set values are not allowed`). Previously an empty set or a set-of-valid-strings fell through cleanly and surfaced a raw `TypeError` from `json.dumps` later. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent acda956 commit 48bced1

4 files changed

Lines changed: 203 additions & 8 deletions

File tree

agentscore_commerce/identity/ucp.py

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ class UCPSigningKey:
5050
extras: dict[str, Any] = field(default_factory=dict)
5151
"""Additional JWK fields (x, y, n, e, etc.) merged into the serialized output."""
5252

53+
_RESERVED = frozenset({"kid", "kty", "alg", "use", "crv"})
54+
5355
def to_dict(self) -> dict[str, Any]:
5456
out: dict[str, Any] = {"kid": self.kid, "kty": self.kty}
5557
if self.alg is not None:
@@ -58,7 +60,11 @@ def to_dict(self) -> dict[str, Any]:
5860
out["use"] = self.use
5961
if self.crv is not None:
6062
out["crv"] = self.crv
61-
out.update(self.extras)
63+
for k, v in self.extras.items():
64+
if k in self._RESERVED:
65+
msg = f"UCPSigningKey.extras key {k!r} collides with a reserved field; rejected."
66+
raise ValueError(msg)
67+
out[k] = v
6268
return out
6369

6470
@classmethod
@@ -110,13 +116,19 @@ class UCPService:
110116
version: str | None = None
111117
extras: dict[str, Any] = field(default_factory=dict)
112118

119+
_RESERVED = frozenset({"type", "url", "version"})
120+
113121
def to_dict(self) -> dict[str, Any]:
114122
out: dict[str, Any] = {"type": self.type}
115123
if self.url is not None:
116124
out["url"] = self.url
117125
if self.version is not None:
118126
out["version"] = self.version
119-
out.update(self.extras)
127+
for k, v in self.extras.items():
128+
if k in self._RESERVED:
129+
msg = f"UCPService.extras key {k!r} collides with a reserved field; rejected."
130+
raise ValueError(msg)
131+
out[k] = v
120132
return out
121133

122134

@@ -129,13 +141,19 @@ class UCPCapability:
129141
version: str | None = None
130142
extras: dict[str, Any] = field(default_factory=dict)
131143

144+
_RESERVED = frozenset({"name", "schema", "version"})
145+
132146
def to_dict(self) -> dict[str, Any]:
133147
out: dict[str, Any] = {"name": self.name}
134148
if self.schema is not None:
135149
out["schema"] = self.schema
136150
if self.version is not None:
137151
out["version"] = self.version
138-
out.update(self.extras)
152+
for k, v in self.extras.items():
153+
if k in self._RESERVED:
154+
msg = f"UCPCapability.extras key {k!r} collides with a reserved field; rejected."
155+
raise ValueError(msg)
156+
out[k] = v
139157
return out
140158

141159

@@ -256,7 +274,24 @@ async def ucp_profile():
256274
if data is not None and data.resolved_operator:
257275
raw = data.raw or {}
258276
operator_verification = raw.get("operator_verification") if isinstance(raw, dict) else None
277+
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
259292
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)
260295
if not isinstance(operator_verification, dict):
261296
operator_verification = {}
262297
if not isinstance(account_verification, dict):

agentscore_commerce/identity/ucp_jwks.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,11 +211,22 @@ def _reject_unsafe_numbers(value: Any) -> None:
211211
)
212212
raise ValueError(msg)
213213
return
214+
# Reject set / frozenset with a typed message (mirrors the node sibling's
215+
# "Set values are not allowed" rejection in stableStringify). Without this,
216+
# an empty set or a set-of-valid-strings falls through `_reject_unsafe_numbers`
217+
# cleanly and surfaces a raw `TypeError` from `json.dumps` later. Sets aren't
218+
# representable in JSON; convert to a sorted list before passing.
219+
if isinstance(value, set | frozenset):
220+
msg = (
221+
f"{type(value).__name__} values are not allowed in canonicalized JSON. "
222+
"Convert to a sorted list before passing."
223+
)
224+
raise ValueError(msg)
214225
if isinstance(value, dict):
215226
for k, v in value.items():
216227
_reject_unsafe_numbers(k)
217228
_reject_unsafe_numbers(v)
218-
elif isinstance(value, list | tuple | set | frozenset):
229+
elif isinstance(value, list | tuple):
219230
for v in value:
220231
_reject_unsafe_numbers(v)
221232

tests/test_ucp.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from agentscore_commerce.identity import (
66
AGENTSCORE_UCP_CAPABILITY,
77
AssessResult,
8+
OperatorVerification,
89
UCPCapability,
910
UCPPaymentHandler,
1011
UCPService,
@@ -179,3 +180,133 @@ def test_coerces_null_verified_at_to_none() -> None:
179180

180181
def test_coerces_empty_string_verified_at_to_none() -> None:
181182
assert _claims_of({"verified_at": ""})["verified_at"] is None
183+
184+
185+
# Typed-field fallback: production callers populate `data.raw`, but a
186+
# hand-constructed AssessResult (no raw) should still surface the operator
187+
# verification block via the typed `AssessResult.operator_verification` field
188+
# and the (optional) `account_verification` attribute. Mirrors the node sibling's
189+
# typed-field read path.
190+
191+
192+
def test_typed_operator_verification_fallback_when_raw_is_none() -> None:
193+
result = AssessResult(
194+
allow=True,
195+
resolved_operator="op_typed",
196+
operator_verification=OperatorVerification(
197+
level="enhanced",
198+
operator_type="api",
199+
verified_at="2026-04-01T00:00:00Z",
200+
),
201+
raw=None,
202+
)
203+
profile = build_ucp_profile(**_base_kwargs(), data=result)
204+
d = profile.to_dict()
205+
cap = next(c for c in d["capabilities"] if c["name"] == AGENTSCORE_UCP_CAPABILITY)
206+
claims = cap["claims"]
207+
assert claims["operator_id"] == "op_typed"
208+
assert claims["kyc_level"] == "enhanced"
209+
assert claims["verified_at"] == "2026-04-01T00:00:00Z"
210+
211+
212+
def test_typed_account_verification_fallback_via_setattr() -> None:
213+
# `AssessResult` doesn't declare `account_verification` as a typed field, but
214+
# a caller can still attach one ad-hoc. The fallback reads it via getattr so
215+
# parity with the node sibling holds whichever way the caller populates it.
216+
result = AssessResult(
217+
allow=True,
218+
resolved_operator="op_typed",
219+
operator_verification=OperatorVerification(level="verified"),
220+
raw=None,
221+
)
222+
result.account_verification = { # type: ignore[attr-defined]
223+
"kyc_level": "verified",
224+
"age_bracket": "21+",
225+
"jurisdiction": "US",
226+
"sanctions_clear": True,
227+
}
228+
profile = build_ucp_profile(**_base_kwargs(), data=result)
229+
d = profile.to_dict()
230+
cap = next(c for c in d["capabilities"] if c["name"] == AGENTSCORE_UCP_CAPABILITY)
231+
claims = cap["claims"]
232+
assert claims["kyc_level"] == "verified"
233+
assert claims["age_bracket"] == "21+"
234+
assert claims["jurisdiction"] == "US"
235+
assert claims["sanctions_clear"] is True
236+
237+
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.
243+
result = AssessResult(
244+
allow=True,
245+
resolved_operator="op_xyz",
246+
operator_verification=OperatorVerification(level="enhanced"),
247+
raw={
248+
"operator_verification": {"level": "verified"},
249+
"account_verification": {"kyc_level": "verified"},
250+
},
251+
)
252+
profile = build_ucp_profile(**_base_kwargs(), data=result)
253+
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.
255+
assert cap.extras["claims"]["kyc_level"] == "verified"
256+
257+
258+
# Per-element to_dict reserved-key collision guard. Mirrors the parent
259+
# UCPProfile.to_dict guard so vendor extras can't silently overwrite a canonical
260+
# field on UCPService / UCPCapability / UCPSigningKey via `out.update(extras)`.
261+
262+
263+
def test_ucp_service_extras_collision_with_type_rejected() -> None:
264+
svc = UCPService(type="rest", extras={"type": "different"})
265+
with pytest.raises(ValueError, match=r"UCPService\.extras key 'type' collides"):
266+
svc.to_dict()
267+
268+
269+
def test_ucp_service_extras_collision_with_url_rejected() -> None:
270+
svc = UCPService(type="rest", url="https://x.example", extras={"url": "https://attacker.example"})
271+
with pytest.raises(ValueError, match=r"UCPService\.extras key 'url' collides"):
272+
svc.to_dict()
273+
274+
275+
def test_ucp_service_extras_non_reserved_pass_through() -> None:
276+
svc = UCPService(type="rest", url="https://x.example", extras={"region": "us-west-1"})
277+
assert svc.to_dict() == {"type": "rest", "url": "https://x.example", "region": "us-west-1"}
278+
279+
280+
def test_ucp_capability_extras_collision_with_name_rejected() -> None:
281+
cap = UCPCapability(name="checkout", extras={"name": "different"})
282+
with pytest.raises(ValueError, match=r"UCPCapability\.extras key 'name' collides"):
283+
cap.to_dict()
284+
285+
286+
def test_ucp_capability_extras_collision_with_schema_rejected() -> None:
287+
cap = UCPCapability(name="checkout", schema="https://x/y", extras={"schema": "https://attacker"})
288+
with pytest.raises(ValueError, match=r"UCPCapability\.extras key 'schema' collides"):
289+
cap.to_dict()
290+
291+
292+
def test_ucp_capability_extras_non_reserved_pass_through() -> None:
293+
cap = UCPCapability(name="checkout", extras={"claims": {"k": "v"}})
294+
assert cap.to_dict() == {"name": "checkout", "claims": {"k": "v"}}
295+
296+
297+
def test_ucp_signing_key_extras_collision_with_kid_rejected() -> None:
298+
sk = UCPSigningKey(kid="me", kty="EC", extras={"kid": "attacker"})
299+
with pytest.raises(ValueError, match=r"UCPSigningKey\.extras key 'kid' collides"):
300+
sk.to_dict()
301+
302+
303+
def test_ucp_signing_key_extras_collision_with_kty_rejected() -> None:
304+
sk = UCPSigningKey(kid="me", kty="EC", extras={"kty": "RSA"})
305+
with pytest.raises(ValueError, match=r"UCPSigningKey\.extras key 'kty' collides"):
306+
sk.to_dict()
307+
308+
309+
def test_ucp_signing_key_extras_non_reserved_pass_through() -> None:
310+
sk = UCPSigningKey(kid="me", kty="EC", alg="ES256", crv="P-256", extras={"x": "abc", "y": "def"})
311+
out = sk.to_dict()
312+
assert out == {"kid": "me", "kty": "EC", "alg": "ES256", "crv": "P-256", "x": "abc", "y": "def"}

tests/test_ucp_jwks.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -314,16 +314,34 @@ def test_accepts_int_and_string(self) -> None:
314314
signed = sign_ucp_profile(profile, signing_key=signer.private_key, kid="k")
315315
assert verify_ucp_profile(signed, build_jwks_response([signer.public_jwk])) is True
316316

317-
def test_rejects_float_in_set(self) -> None:
317+
def test_rejects_set_values_outright(self) -> None:
318+
# `set` is not representable in JSON; the canonicalizer rejects it with a
319+
# typed message before any element-level checks run. Mirrors node's
320+
# `stableStringify: Set values are not allowed`.
318321
signer = generate_ucp_signing_key(kid="k")
319322
profile = {**_base_profile([signer.public_jwk]), "extras": {"vals": {0.5}}}
320-
with pytest.raises(ValueError, match="rejects float"):
323+
with pytest.raises(ValueError, match="set values are not allowed"):
321324
sign_ucp_profile(profile, signing_key=signer.private_key, kid="k")
322325

323-
def test_rejects_float_in_frozenset(self) -> None:
326+
def test_rejects_frozenset_values_outright(self) -> None:
324327
signer = generate_ucp_signing_key(kid="k")
325328
profile = {**_base_profile([signer.public_jwk]), "extras": {"vals": frozenset({0.25})}}
326-
with pytest.raises(ValueError, match="rejects float"):
329+
with pytest.raises(ValueError, match="frozenset values are not allowed"):
330+
sign_ucp_profile(profile, signing_key=signer.private_key, kid="k")
331+
332+
def test_rejects_empty_set_with_typed_message(self) -> None:
333+
# Empty set + set-of-valid-strings would fall through `_reject_unsafe_numbers`
334+
# cleanly and surface a raw `TypeError` from `json.dumps` later. The typed
335+
# reject ensures callers get a guiding ValueError instead.
336+
signer = generate_ucp_signing_key(kid="k")
337+
profile = {**_base_profile([signer.public_jwk]), "extras": {"vals": set()}}
338+
with pytest.raises(ValueError, match="set values are not allowed"):
339+
sign_ucp_profile(profile, signing_key=signer.private_key, kid="k")
340+
341+
def test_rejects_set_of_valid_strings_with_typed_message(self) -> None:
342+
signer = generate_ucp_signing_key(kid="k")
343+
profile = {**_base_profile([signer.public_jwk]), "extras": {"vals": {"valid", "strings"}}}
344+
with pytest.raises(ValueError, match="set values are not allowed"):
327345
sign_ucp_profile(profile, signing_key=signer.private_key, kid="k")
328346

329347
def test_accepts_max_safe_int_boundary(self) -> None:

0 commit comments

Comments
 (0)