diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index d78bbd3..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 `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 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..f593564 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,38 @@ 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. + +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 @@ -195,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 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..3ba5de5 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) @@ -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() diff --git a/agentscore/types.py b/agentscore/types.py index 1c5a577..f63accc 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 @@ -187,15 +187,15 @@ class ResolveSigner(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 - ``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. - 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,65 @@ 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): + """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``. + An :class:`SignerSanctionsUnavailable` verdict under the same policy yields + ``decision_reasons`` including ``sanctions_check_unavailable`` (fail-closed). + """ + + 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): @@ -264,8 +306,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] @@ -396,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/pyproject.toml b/pyproject.toml index 4e6034c..e321f1f 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.3.0" description = "Python client for the AgentScore APIs" readme = "README.md" license = "MIT" diff --git a/tests/test_client.py b/tests/test_client.py index 98fa3fe..e7fd110 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -274,6 +274,134 @@ 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() + + +@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 # --------------------------------------------------------------------------- diff --git a/uv.lock b/uv.lock index 95ab467..b028258 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.3.0" source = { editable = "." } dependencies = [ { name = "httpx" },