Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
6 changes: 4 additions & 2 deletions agentscore/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,12 @@
Reputation,
ReputationResponse,
ReputationStatus,
ResolveSigner,
SessionCreateRequest,
SessionCreateResponse,
SessionPollResponse,
Signer,
SignerMatch,
SignerSanctions,
VerificationLevel,
WalletAuthRequiresSigningBody,
WalletSignerMismatchBody,
Expand Down Expand Up @@ -74,11 +75,12 @@
"Reputation",
"ReputationResponse",
"ReputationStatus",
"ResolveSigner",
"SessionCreateRequest",
"SessionCreateResponse",
"SessionPollResponse",
"Signer",
"SignerMatch",
"SignerSanctions",
"TimeoutError",
"TokenExpiredError",
"VerificationLevel",
Expand Down
26 changes: 13 additions & 13 deletions agentscore/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,9 @@ def _build_error_from_response(response: httpx.Response) -> AgentScoreError:
DecisionPolicy,
Network,
ReputationResponse,
ResolveSigner,
SessionCreateResponse,
SessionPollResponse,
Signer,
)


Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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] = {}
Expand All @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
72 changes: 59 additions & 13 deletions agentscore/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading