From 4fd883a5fa29f519c279ba1db9ecd8f5d7026c19 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Mon, 11 May 2026 08:21:04 -0700 Subject: [PATCH 1/6] feat: signer field + signer_sanctions verdict (TEC-295 Phase 2b) Python-side mirror of agentscore/node-sdk#TBD and the api change in agentscore/core#TBD. Breaking field rename plus a new response type: * assess(resolve_signer=...) -> assess(signer=...) on both sync + async * request body field resolve_signer -> signer (snake_case wire) * ResolveSigner type -> Signer * New SignerSanctions discriminated union: SignerSanctionsClear | SignerSanctionsHit | SignerSanctionsUnavailable * New AssessResponse.signer_sanctions optional field * __init__.py exports updated; ResolveSigner removed from public API No back-compat alias. Callers passing `resolve_signer=...` get a TypeError at call time. The api silently ignores `resolve_signer` request fields if any straggler send them; this SDK won't. Version 2.1.2 -> 2.2.0. Minor bump rather than major because internal consumers (agentscore-commerce + pay) are the primary users and the TypedDict surface catches the rename at type-check time. CLAUDE.md + README + tests updated. 152/152 tests pass; ruff + ty + uv.lock clean. uv.lock refreshed via uv sync --upgrade. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/CLAUDE.md | 2 +- agentscore/__init__.py | 6 ++++-- agentscore/client.py | 20 ++++++++--------- agentscore/types.py | 49 +++++++++++++++++++++++++++++++++++++++--- pyproject.toml | 2 +- uv.lock | 2 +- 6 files changed, 63 insertions(+), 18 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index d78bbd3..80c5e27 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -9,7 +9,7 @@ Two identity paths: `X-Wallet-Address` (wallet-based) and `X-Operator-Token` (cr ## Methods (sync + async) - `get_reputation` / `aget_reputation` — cached reputation lookup (free) -- `assess` / `aassess` — identity gate with policy (paid). Accepts `operator_token` for non-wallet agents. Response includes `linked_wallets[]` and `resolved_operator`. Optional `resolve_signer: { address, network }` opts into server-side wallet-signer-match — the response then carries a `signer_match` block describing whether the supplied signer wallet resolves to the same operator as the claimed `address`. +- `assess` / `aassess` — identity gate with policy (paid). Accepts `operator_token` for non-wallet agents. Response includes `linked_wallets[]` and `resolved_operator`. Optional `signer: { address, network }` opts into server-side wallet-signer-match — the response then carries a `signer_match` block describing whether the supplied signer wallet resolves to the same operator as the claimed `address`. - `create_session` / `acreate_session` — create verification session. Returns `agent_memory` + `next_steps`. - `poll_session` / `apoll_session` — poll session status, returns credential when verified, plus `next_steps.action`. - `create_credential` / `acreate_credential` — create operator credential (24h TTL default). Response includes `agent_memory`. diff --git a/agentscore/__init__.py b/agentscore/__init__.py index f61cb77..d90bb67 100644 --- a/agentscore/__init__.py +++ b/agentscore/__init__.py @@ -33,11 +33,12 @@ Reputation, ReputationResponse, ReputationStatus, - ResolveSigner, SessionCreateRequest, SessionCreateResponse, SessionPollResponse, + Signer, SignerMatch, + SignerSanctions, VerificationLevel, WalletAuthRequiresSigningBody, WalletSignerMismatchBody, @@ -74,11 +75,12 @@ "Reputation", "ReputationResponse", "ReputationStatus", - "ResolveSigner", "SessionCreateRequest", "SessionCreateResponse", "SessionPollResponse", + "Signer", "SignerMatch", + "SignerSanctions", "TimeoutError", "TokenExpiredError", "VerificationLevel", diff --git a/agentscore/client.py b/agentscore/client.py index 47857e8..8ad7781 100644 --- a/agentscore/client.py +++ b/agentscore/client.py @@ -137,9 +137,9 @@ def _build_error_from_response(response: httpx.Response) -> AgentScoreError: DecisionPolicy, Network, ReputationResponse, - ResolveSigner, SessionCreateResponse, SessionPollResponse, + Signer, ) @@ -255,13 +255,13 @@ def assess( refresh: bool | None = None, policy: DecisionPolicy | None = None, operator_token: str | None = None, - resolve_signer: ResolveSigner | None = None, + signer: Signer | None = None, ) -> AssessResponse: """Assess a wallet or operator (paid, writes score on-the-fly). - ``resolve_signer`` opts into server-side wallet-signer-match: when supplied, + ``signer`` opts into server-side wallet-signer-match: when supplied, the API resolves the signer wallet against the claimed ``address`` and emits - a ``signer_match`` block on the response. See :class:`ResolveSigner`. + a ``signer_match`` block on the response. See :class:`Signer`. """ body: dict[str, Any] = {} if address: @@ -274,8 +274,8 @@ def assess( body["refresh"] = refresh if policy is not None: body["policy"] = dict(policy) - if resolve_signer is not None: - body["resolve_signer"] = dict(resolve_signer) + if signer is not None: + body["signer"] = dict(signer) client = self._get_sync_client() data, response = self._send_sync_with_response(lambda: client.post("/v1/assess", json=body)) quota = _extract_quota(response) @@ -393,11 +393,11 @@ async def aassess( refresh: bool | None = None, policy: DecisionPolicy | None = None, operator_token: str | None = None, - resolve_signer: ResolveSigner | None = None, + signer: Signer | None = None, ) -> AssessResponse: """Assess a wallet or operator (paid, writes score on-the-fly). - ``resolve_signer`` opts into server-side wallet-signer-match — async mirror of + ``signer`` opts into server-side wallet-signer-match — async mirror of :meth:`assess`. """ body: dict[str, Any] = {} @@ -411,8 +411,8 @@ async def aassess( body["refresh"] = refresh if policy is not None: body["policy"] = dict(policy) - if resolve_signer is not None: - body["resolve_signer"] = dict(resolve_signer) + if signer is not None: + body["signer"] = dict(signer) client = self._get_async_client() data, response = await self._send_async_with_response(lambda: client.post("/v1/assess", json=body)) quota = _extract_quota(response) diff --git a/agentscore/types.py b/agentscore/types.py index 1c5a577..edcc737 100644 --- a/agentscore/types.py +++ b/agentscore/types.py @@ -169,7 +169,7 @@ class DecisionPolicy(TypedDict, total=False): allowed_jurisdictions: list[str] -class ResolveSigner(TypedDict): +class Signer(TypedDict): """Server-side wallet-signer-match request. When passed to ``assess()`` / ``aassess()``, the API resolves this signer wallet @@ -191,7 +191,7 @@ class SignerMatch(TypedDict, total=False): """Server-side wallet-signer-match verdict. Emitted on ``AssessResponse.signer_match`` when the request supplied - ``resolve_signer``. Mirrors the verdict shape commerce SDK gates produce locally; + ``signer``. Mirrors the verdict shape commerce SDK gates produce locally; SDK consumers spread this into 403 bodies verbatim instead of re-deriving via 2 extra ``/v1/assess`` round trips. @@ -223,6 +223,46 @@ class SignerMatch(TypedDict, total=False): agent_instructions: str +class SignerSanctionsClear(TypedDict): + """Server-side wallet-sanctions verdict — address NOT on the OFAC SDN list.""" + + status: Literal["clear"] + + +class SignerSanctionsHit(TypedDict): + """Server-side wallet-sanctions verdict — address IS on the OFAC SDN list. + + Under ``policy.require_sanctions_clear``, this verdict flips the response + ``decision`` to ``deny`` with ``decision_reasons`` including ``sanctions_flagged``. + """ + + sanctioned: Literal[True] + # Raw OFAC Digital Currency Address label the hit was published under (``ETH``, + # ``XBT``, ``USDT``, ``SOL``, ...). Investigation-history metadata; the gate's + # enforcement axis is the format-classified family, not this label. + ofac_label: str + # SDN entry's Identity ID. Same ``sdn_uid`` may surface multiple addresses (one + # entity, multiple wallets); join key for audit. + sdn_uid: str + # ISO date OFAC initially designated the entity. ``None`` if upstream omits. + listed_at: str | None + + +class SignerSanctionsUnavailable(TypedDict): + """Server-side wallet-sanctions verdict — lookup itself failed. + + Under ``policy.require_sanctions_clear``, the gate fail-closes — falsely allowing + a sanctioned settle is an OFAC strict-liability violation; falsely denying a clean + buyer is bad UX. + """ + + status: Literal["unavailable"] + + +# Discriminated union: branch on ``status`` (clear/unavailable) vs ``sanctioned`` (True). +SignerSanctions = SignerSanctionsClear | SignerSanctionsHit | SignerSanctionsUnavailable + + class _AssessResponseRequired(TypedDict): decision: str | None decision_reasons: list[str] @@ -264,8 +304,11 @@ class AssessResponse(_AssessResponseRequired, total=False): policy_result: PolicyResult | None explanation: NotRequired[list[PolicyExplanation]] # Server-side wallet-signer-match verdict, returned only when the request supplied - # ``resolve_signer``. Empty otherwise. + # ``signer``. Empty otherwise. signer_match: NotRequired[SignerMatch] + # Server-side OFAC SDN wallet-address verdict, returned only when the request supplied + # ``signer``. Empty otherwise. + signer_sanctions: NotRequired[SignerSanctions] # Quota state for this account, captured from response headers on the success path. # Use to monitor approach-to-cap proactively (warn at 80%, alert at 95%) before 429. quota: NotRequired[QuotaInfo] diff --git a/pyproject.toml b/pyproject.toml index 4e6034c..2def454 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "agentscore-py" -version = "2.1.2" +version = "2.2.0" description = "Python client for the AgentScore APIs" readme = "README.md" license = "MIT" diff --git a/uv.lock b/uv.lock index 95ab467..e024081 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.11" [[package]] name = "agentscore-py" -version = "2.1.2" +version = "2.2.0" source = { editable = "." } dependencies = [ { name = "httpx" }, From 4fe964e9da30fd50fc8c1ad177f49e54e3b9dbfe Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Mon, 11 May 2026 10:18:36 -0700 Subject: [PATCH 2/6] docs: drop stale verify_wallet_signer_match reference on telemetry_signer_match --- agentscore/client.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/agentscore/client.py b/agentscore/client.py index 8ad7781..3ba5de5 100644 --- a/agentscore/client.py +++ b/agentscore/client.py @@ -507,9 +507,9 @@ async def aassociate_wallet( def telemetry_signer_match(self, payload: dict[str, Any]) -> None: """Fire-and-forget telemetry — report a wallet-signer-match verdict. - Used internally by the commerce gate's ``verify_wallet_signer_match`` helper to track - aggregate signer-binding behavior across merchants. Does not raise; failures are - logged at warning level so persistent telemetry outages are visible in ops logs. + Tracks aggregate signer-binding behavior across merchants. Does not raise; + failures are logged at warning level so persistent telemetry outages are visible + in ops logs. """ try: client = self._get_sync_client() From 80cd36f074684fe6d1049c8f6bb31524c3ba5580 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Mon, 11 May 2026 11:19:45 -0700 Subject: [PATCH 3/6] feat: signer assess tests + README + CLAUDE docs Adds 6 pytest tests covering the new signer surface end-to-end: - assess(signer=...) forwards the signer in request body - assess() without signer omits it - response carries signer_match + signer_sanctions blocks together - signer_sanctions clear variant - signer_sanctions unavailable variant - aassess(signer=...) async mirror README + CLAUDE.md document the new signer request param and the signer_sanctions discriminated-union response shape, including the fail-closed policy.require_sanctions_clear semantics. SignerSanctionsHit docstring now mentions both sanctions_flagged AND sanctions_check_unavailable for parity with the node-sdk docstring. --- .claude/CLAUDE.md | 2 +- README.md | 30 ++++++++++++++++ agentscore/types.py | 2 ++ tests/test_client.py | 85 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 1 deletion(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 80c5e27..6f95fb5 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -9,7 +9,7 @@ Two identity paths: `X-Wallet-Address` (wallet-based) and `X-Operator-Token` (cr ## Methods (sync + async) - `get_reputation` / `aget_reputation` — cached reputation lookup (free) -- `assess` / `aassess` — identity gate with policy (paid). Accepts `operator_token` for non-wallet agents. Response includes `linked_wallets[]` and `resolved_operator`. Optional `signer: { address, network }` opts into server-side wallet-signer-match — the response then carries a `signer_match` block describing whether the supplied signer wallet resolves to the same operator as the claimed `address`. +- `assess` / `aassess` — identity gate with policy (paid). Accepts `operator_token` for non-wallet agents. Response includes `linked_wallets[]` and `resolved_operator`. Optional `signer: { address, network }` opts into server-side wallet-signer-match AND OFAC SDN wallet-address screening — the response then carries both a `signer_match` block (wallet-binding verdict: `pass` / `wallet_signer_mismatch` / `wallet_auth_requires_wallet_signing`) and a `signer_sanctions` block (discriminated union: `{status: "clear"}` | `{sanctioned: True, ofac_label, sdn_uid, listed_at}` | `{status: "unavailable"}`). Under `policy.require_sanctions_clear`, a `sanctioned: True` OR `status: "unavailable"` verdict flips the response `decision` to `deny` with `decision_reasons` including `sanctions_flagged` or `sanctions_check_unavailable` respectively (fail-closed; OFAC strict-liability). - `create_session` / `acreate_session` — create verification session. Returns `agent_memory` + `next_steps`. - `poll_session` / `apoll_session` — poll session status, returns credential when verified, plus `next_steps.action`. - `create_credential` / `acreate_credential` — create operator credential (24h TTL default). Response includes `agent_memory`. diff --git a/README.md b/README.md index 8dd93e6..51f9f50 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,36 @@ client.create_session(operator_token="opc_...") # KYC refresh `assess()` responses include `resolved_operator` and `linked_wallets` — all same-operator sibling wallets (claimed via SIWE or captured via prior `associate_wallet`). The list may mix EVM addresses (`0x...` lowercased) and Solana addresses (base58, case-preserved) for cross-chain operators; merchants doing wallet-signer-match checks should accept a payment signed by any address in the list, regardless of chain. The `address` parameter on `assess()` and `get_reputation()` accepts either format — network is auto-detected from the address shape. +### Server-side signer-match + sanctions screening + +Pass `signer={"address", "network"}` on `assess()` / `aassess()` to opt into server-side wallet-signer-match and OFAC SDN wallet-address screening on the same call. The response carries two new verdicts: + +```python +result = client.assess( + "0xclaimed...", + signer={"address": "0xsigner...", "network": "evm"}, + policy={"require_sanctions_clear": True}, +) + +# signer_match: wallet-binding verdict +# kind: 'pass' | 'wallet_signer_mismatch' | 'wallet_auth_requires_wallet_signing' +# plus claimed_operator / signer_operator / expected_signer / actual_signer / +# linked_wallets / agent_instructions +match = result.get("signer_match") +if match and match.get("kind") == "wallet_signer_mismatch": + # signer wallet resolves to a different operator than the claimed address + ... + +# signer_sanctions: OFAC SDN wallet-address verdict (discriminated union) +# {"status": "clear"} | {"sanctioned": True, "ofac_label", "sdn_uid", "listed_at"} +# | {"status": "unavailable"} +sanctions = result.get("signer_sanctions") +if sanctions and sanctions.get("sanctioned"): + print("OFAC hit:", sanctions["ofac_label"], sanctions["sdn_uid"]) +``` + +Under `policy.require_sanctions_clear`, the API flips `decision` to `deny` when `signer_sanctions` is `sanctioned: True` OR `status: "unavailable"` — `decision_reasons` will include `sanctions_flagged` or `sanctions_check_unavailable` respectively (fail-closed; OFAC strict-liability). Without the policy flag, both verdicts are informational. + ### Credential Management ```python diff --git a/agentscore/types.py b/agentscore/types.py index edcc737..ca4105a 100644 --- a/agentscore/types.py +++ b/agentscore/types.py @@ -234,6 +234,8 @@ class SignerSanctionsHit(TypedDict): Under ``policy.require_sanctions_clear``, this verdict flips the response ``decision`` to ``deny`` with ``decision_reasons`` including ``sanctions_flagged``. + An :class:`SignerSanctionsUnavailable` verdict under the same policy yields + ``decision_reasons`` including ``sanctions_check_unavailable`` (fail-closed). """ sanctioned: Literal[True] diff --git a/tests/test_client.py b/tests/test_client.py index 98fa3fe..52afd8b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -274,6 +274,91 @@ def test_assess_forwards_policy_in_body(): assert body["policy"] == policy +# --------------------------------------------------------------------------- +# assess: signer (server-side wallet-signer-match + sanctions screening) +# --------------------------------------------------------------------------- + + +@respx.mock +def test_assess_forwards_signer_in_body(): + """assess(signer={...}) opts into server-side wallet-signer-match + signer-sanctions.""" + route = respx.post(f"{BASE_URL}/v1/assess").mock(return_value=httpx.Response(200, json=ASSESS_PAYLOAD)) + client = AgentScore(api_key=API_KEY) + signer = {"address": "0xsigner000000000000000000000000000000abc1", "network": "evm"} + client.assess(ADDRESS, signer=signer) + body = json.loads(route.calls.last.request.content) + assert body["signer"] == signer + + +@respx.mock +def test_assess_signer_omitted_when_none(): + route = respx.post(f"{BASE_URL}/v1/assess").mock(return_value=httpx.Response(200, json=ASSESS_PAYLOAD)) + client = AgentScore(api_key=API_KEY) + client.assess(ADDRESS) + body = json.loads(route.calls.last.request.content) + assert "signer" not in body + + +@respx.mock +def test_assess_returns_signer_match_and_signer_sanctions(): + """Response surface: signer_match (wallet-binding) + signer_sanctions (OFAC SDN) compose on the same call.""" + payload = { + **ASSESS_PAYLOAD, + "signer_match": { + "kind": "wallet_signer_mismatch", + "claimed_operator": "op_claimed", + "signer_operator": "op_attacker", + "expected_signer": "0xclaimed", + "actual_signer": "0xattacker", + "linked_wallets": ["0xclaimed"], + "agent_instructions": '{"action":"resign_or_switch_to_operator_token","steps":[],"user_message":"x"}', + }, + "signer_sanctions": { + "sanctioned": True, + "ofac_label": "ETH", + "sdn_uid": "19011", + "listed_at": "2019-09-13T07:00:00.000Z", + }, + } + respx.post(f"{BASE_URL}/v1/assess").mock(return_value=httpx.Response(200, json=payload)) + client = AgentScore(api_key=API_KEY) + result = client.assess(ADDRESS, signer={"address": "0xattacker", "network": "evm"}) + assert result["signer_match"]["kind"] == "wallet_signer_mismatch" + assert result["signer_match"]["expected_signer"] == "0xclaimed" + assert result["signer_sanctions"]["sanctioned"] is True + assert result["signer_sanctions"]["ofac_label"] == "ETH" + + +@respx.mock +def test_assess_returns_signer_sanctions_clear(): + payload = {**ASSESS_PAYLOAD, "signer_sanctions": {"status": "clear"}} + respx.post(f"{BASE_URL}/v1/assess").mock(return_value=httpx.Response(200, json=payload)) + client = AgentScore(api_key=API_KEY) + result = client.assess(ADDRESS, signer={"address": "0xsigner", "network": "evm"}) + assert result["signer_sanctions"]["status"] == "clear" + + +@respx.mock +def test_assess_returns_signer_sanctions_unavailable(): + payload = {**ASSESS_PAYLOAD, "signer_sanctions": {"status": "unavailable"}} + respx.post(f"{BASE_URL}/v1/assess").mock(return_value=httpx.Response(200, json=payload)) + client = AgentScore(api_key=API_KEY) + result = client.assess(ADDRESS, signer={"address": "0xsigner", "network": "evm"}) + assert result["signer_sanctions"]["status"] == "unavailable" + + +@pytest.mark.asyncio +@respx.mock +async def test_aassess_forwards_signer_in_body(): + route = respx.post(f"{BASE_URL}/v1/assess").mock(return_value=httpx.Response(200, json=ASSESS_PAYLOAD)) + client = AgentScore(api_key=API_KEY) + signer = {"address": "0xsignerasync", "network": "solana"} + await client.aassess(ADDRESS, signer=signer) + body = json.loads(route.calls.last.request.content) + assert body["signer"] == signer + await client.aclose() + + # --------------------------------------------------------------------------- # Async: aget_reputation # --------------------------------------------------------------------------- From c381214c130373f29a35a897bef67ffb93426de4 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Mon, 11 May 2026 11:30:45 -0700 Subject: [PATCH 4/6] types: SignerMatch.kind required + AssociateWalletResponse.associated Literal[True] Brings SignerMatch in line with node-sdk: `kind` is always emitted (it's the discriminator) so it should be required; the other fields stay NotRequired since they depend on which kind was emitted. Tightens AssociateWalletResponse to match wire reality (API only emits true on 2xx; failures raise instead). Adds 3 new tests: - assess(signer={address: solana_base58}) preserves base58 case (no lowercasing) - assess(signer={address: None}) for rails with no wallet signer - TokenExpiredError raised on 401 even when signer was sent (signer still wired) --- agentscore/types.py | 21 +++++++++++---------- tests/test_client.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/agentscore/types.py b/agentscore/types.py index ca4105a..f63accc 100644 --- a/agentscore/types.py +++ b/agentscore/types.py @@ -187,7 +187,7 @@ class Signer(TypedDict): network: Literal["evm", "solana"] -class SignerMatch(TypedDict, total=False): +class SignerMatch(TypedDict): """Server-side wallet-signer-match verdict. Emitted on ``AssessResponse.signer_match`` when the request supplied @@ -195,7 +195,7 @@ class SignerMatch(TypedDict, total=False): SDK consumers spread this into 403 bodies verbatim instead of re-deriving via 2 extra ``/v1/assess`` round trips. - Fields populated depend on ``kind``. + ``kind`` is always present; other fields depend on which kind was emitted. """ # ``pass`` — claimed wallet and signer wallet resolve to the same operator (or are @@ -204,23 +204,23 @@ class SignerMatch(TypedDict, total=False): # has no wallet signer); agent should switch to operator_token auth. kind: Literal["pass", "wallet_signer_mismatch", "wallet_auth_requires_wallet_signing"] # Operator the claimed wallet resolves to. ``None`` if unlinked. - claimed_operator: str | None + claimed_operator: NotRequired[str | None] # Operator the signer wallet resolves to. ``None`` if unlinked. - signer_operator: str | None + signer_operator: NotRequired[str | None] # Echoed only on ``wallet_auth_requires_wallet_signing`` — the claimed wallet from # the request. Helps agents construct the recovery message. - claimed_wallet: str + claimed_wallet: NotRequired[str] # Echoed on ``wallet_signer_mismatch`` — the claimed wallet, normalized. - expected_signer: str + expected_signer: NotRequired[str] # Echoed on ``wallet_signer_mismatch`` — the signer wallet, normalized. - actual_signer: str + actual_signer: NotRequired[str] # Same-operator linked wallets the agent could re-sign from to satisfy the claim. # Mirrors the top-level ``linked_wallets`` deny-guard — omitted on ``deny`` verdicts. - linked_wallets: list[str] + linked_wallets: NotRequired[list[str]] # JSON-encoded ``{action, steps, user_message}`` envelope for SDK denial bodies. # Authoritative copy lives server-side; SDK consumers spread this into their 403 # body without re-parsing. - agent_instructions: str + agent_instructions: NotRequired[str] class SignerSanctionsClear(TypedDict): @@ -441,7 +441,8 @@ class CredentialCreateErrorResponse(TypedDict): class AssociateWalletResponse(TypedDict): - associated: bool + # Always ``True`` on 2xx; failures surface via :class:`AgentScoreError` instead. + associated: Literal[True] first_seen: bool deduped: NotRequired[bool] # Cross-merchant pattern hint. Emitted only on the first wallet capture (first_seen=True) diff --git a/tests/test_client.py b/tests/test_client.py index 52afd8b..e7fd110 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -359,6 +359,49 @@ async def test_aassess_forwards_signer_in_body(): await client.aclose() +@respx.mock +def test_assess_signer_preserves_solana_case(): + """Solana addresses are case-sensitive; signer must thread through unmodified.""" + route = respx.post(f"{BASE_URL}/v1/assess").mock(return_value=httpx.Response(200, json=ASSESS_PAYLOAD)) + client = AgentScore(api_key=API_KEY) + # Real-shape Solana base58 mixed case + sol = "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy" + client.assess(ADDRESS, signer={"address": sol, "network": "solana"}) + body = json.loads(route.calls.last.request.content) + assert body["signer"]["address"] == sol # byte-equal, no lowercasing + + +@respx.mock +def test_assess_signer_address_null_forwarded(): + """signer.address=None for rails with no wallet signer (Stripe SPT, card).""" + route = respx.post(f"{BASE_URL}/v1/assess").mock(return_value=httpx.Response(200, json=ASSESS_PAYLOAD)) + client = AgentScore(api_key=API_KEY) + client.assess(ADDRESS, signer={"address": None, "network": "evm"}) + body = json.loads(route.calls.last.request.content) + assert body["signer"] == {"address": None, "network": "evm"} + + +@respx.mock +def test_assess_signer_raises_token_expired_with_signer(): + """signer + 401 token_expired: TokenExpiredError raised; signer was still sent.""" + route = respx.post(f"{BASE_URL}/v1/assess").mock( + return_value=httpx.Response( + 401, + json={ + "error": {"code": "token_expired", "message": "expired"}, + "verify_url": "https://example/verify", + }, + ) + ) + from agentscore.errors import TokenExpiredError + + client = AgentScore(api_key=API_KEY) + with pytest.raises(TokenExpiredError): + client.assess(operator_token="opc_expired", signer={"address": "0xs", "network": "evm"}) + body = json.loads(route.calls.last.request.content) + assert body["signer"] == {"address": "0xs", "network": "evm"} + + # --------------------------------------------------------------------------- # Async: aget_reputation # --------------------------------------------------------------------------- From 7a552cf83f5f39fcde126c8cbb6d9a35e15a7f07 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Mon, 11 May 2026 11:33:03 -0700 Subject: [PATCH 5/6] docs: README signer-null-address + 429 quota path clarifications --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 51f9f50..f593564 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,8 @@ if sanctions and sanctions.get("sanctioned"): Under `policy.require_sanctions_clear`, the API flips `decision` to `deny` when `signer_sanctions` is `sanctioned: True` OR `status: "unavailable"` — `decision_reasons` will include `sanctions_flagged` or `sanctions_check_unavailable` respectively (fail-closed; OFAC strict-liability). Without the policy flag, both verdicts are informational. +Pass `signer["address"] = None` for rails without a wallet signer (Stripe SPT, card-only). The API responds with `signer_match["kind"] == "wallet_auth_requires_wallet_signing"` and a parsed `agent_instructions` block telling the agent to switch to `X-Operator-Token` auth — spread the block directly into a 403 body. + ### Credential Management ```python @@ -225,7 +227,7 @@ if quota and quota["limit"] and quota["used"]: print(f"AgentScore quota at {pct:.1f}% — resets {quota['reset']}") ``` -`quota` is absent when the API doesn't emit the headers (Enterprise / unlimited tiers). +`quota` is absent when the API doesn't emit the headers (Enterprise / unlimited tiers). On a 429 response the SDK raises `QuotaExceededError` / `RateLimitedError` instead of returning a body, so `quota` is only readable on successful calls — drive proactive alerting off the success-path field. ## Telemetry From 6740dccd67fb0e1a90f4d73062ae388c3b337843 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Mon, 11 May 2026 11:41:03 -0700 Subject: [PATCH 6/6] chore: bump version to 2.3.0 (leapfrog yanked 2.2.1) agentscore-py 2.2.1 was published in error and yanked on PyPI ("Incorrectly Published"). 2.2.0 < 2.2.1 means downstream pip resolution would still see the yank gap; jump straight to 2.3.0 to leapfrog the yanked number AND match @agent-score/sdk's 2.3.0 cadence (same TEC-295 surface lands on both). --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2def454..e321f1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "agentscore-py" -version = "2.2.0" +version = "2.3.0" description = "Python client for the AgentScore APIs" readme = "README.md" license = "MIT" diff --git a/uv.lock b/uv.lock index e024081..b028258 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.11" [[package]] name = "agentscore-py" -version = "2.2.0" +version = "2.3.0" source = { editable = "." } dependencies = [ { name = "httpx" },