agentscore-py 2.1.2__tar.gz → 2.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. {agentscore_py-2.1.2 → agentscore_py-2.3.0}/.claude/CLAUDE.md +1 -1
  2. {agentscore_py-2.1.2 → agentscore_py-2.3.0}/PKG-INFO +34 -2
  3. {agentscore_py-2.1.2 → agentscore_py-2.3.0}/README.md +33 -1
  4. {agentscore_py-2.1.2 → agentscore_py-2.3.0}/agentscore/__init__.py +4 -2
  5. {agentscore_py-2.1.2 → agentscore_py-2.3.0}/agentscore/client.py +13 -13
  6. {agentscore_py-2.1.2 → agentscore_py-2.3.0}/agentscore/types.py +59 -13
  7. {agentscore_py-2.1.2 → agentscore_py-2.3.0}/pyproject.toml +1 -1
  8. {agentscore_py-2.1.2 → agentscore_py-2.3.0}/tests/test_client.py +128 -0
  9. {agentscore_py-2.1.2 → agentscore_py-2.3.0}/uv.lock +1 -1
  10. {agentscore_py-2.1.2 → agentscore_py-2.3.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  11. {agentscore_py-2.1.2 → agentscore_py-2.3.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  12. {agentscore_py-2.1.2 → agentscore_py-2.3.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  13. {agentscore_py-2.1.2 → agentscore_py-2.3.0}/.github/dependabot.yml +0 -0
  14. {agentscore_py-2.1.2 → agentscore_py-2.3.0}/.github/workflows/ci.yml +0 -0
  15. {agentscore_py-2.1.2 → agentscore_py-2.3.0}/.github/workflows/publish.yml +0 -0
  16. {agentscore_py-2.1.2 → agentscore_py-2.3.0}/.github/workflows/security.yml +0 -0
  17. {agentscore_py-2.1.2 → agentscore_py-2.3.0}/.gitignore +0 -0
  18. {agentscore_py-2.1.2 → agentscore_py-2.3.0}/CODE_OF_CONDUCT.md +0 -0
  19. {agentscore_py-2.1.2 → agentscore_py-2.3.0}/CONTRIBUTING.md +0 -0
  20. {agentscore_py-2.1.2 → agentscore_py-2.3.0}/LICENSE +0 -0
  21. {agentscore_py-2.1.2 → agentscore_py-2.3.0}/SECURITY.md +0 -0
  22. {agentscore_py-2.1.2 → agentscore_py-2.3.0}/agentscore/errors.py +0 -0
  23. {agentscore_py-2.1.2 → agentscore_py-2.3.0}/agentscore/py.typed +0 -0
  24. {agentscore_py-2.1.2 → agentscore_py-2.3.0}/agentscore/test_mode.py +0 -0
  25. {agentscore_py-2.1.2 → agentscore_py-2.3.0}/lefthook.yml +0 -0
  26. {agentscore_py-2.1.2 → agentscore_py-2.3.0}/ruff.toml +0 -0
  27. {agentscore_py-2.1.2 → agentscore_py-2.3.0}/tests/__init__.py +0 -0
  28. {agentscore_py-2.1.2 → agentscore_py-2.3.0}/tests/conftest.py +0 -0
  29. {agentscore_py-2.1.2 → agentscore_py-2.3.0}/tests/test_errors.py +0 -0
  30. {agentscore_py-2.1.2 → agentscore_py-2.3.0}/tests/test_integration.py +0 -0
  31. {agentscore_py-2.1.2 → agentscore_py-2.3.0}/tests/test_test_mode.py +0 -0
  32. {agentscore_py-2.1.2 → agentscore_py-2.3.0}/tests/test_types.py +0 -0
  33. {agentscore_py-2.1.2 → agentscore_py-2.3.0}/vulture_whitelist.py +0 -0
@@ -9,7 +9,7 @@ Two identity paths: `X-Wallet-Address` (wallet-based) and `X-Operator-Token` (cr
9
9
  ## Methods (sync + async)
10
10
 
11
11
  - `get_reputation` / `aget_reputation` — cached reputation lookup (free)
12
- - `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`.
12
+ - `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).
13
13
  - `create_session` / `acreate_session` — create verification session. Returns `agent_memory` + `next_steps`.
14
14
  - `poll_session` / `apoll_session` — poll session status, returns credential when verified, plus `next_steps.action`.
15
15
  - `create_credential` / `acreate_credential` — create operator credential (24h TTL default). Response includes `agent_memory`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentscore-py
3
- Version: 2.1.2
3
+ Version: 2.3.0
4
4
  Summary: Python client for the AgentScore APIs
5
5
  Project-URL: Homepage, https://agentscore.sh
6
6
  Project-URL: Repository, https://github.com/agentscore/python-sdk
@@ -96,6 +96,38 @@ client.create_session(operator_token="opc_...") # KYC refresh
96
96
 
97
97
  `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.
98
98
 
99
+ ### Server-side signer-match + sanctions screening
100
+
101
+ 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:
102
+
103
+ ```python
104
+ result = client.assess(
105
+ "0xclaimed...",
106
+ signer={"address": "0xsigner...", "network": "evm"},
107
+ policy={"require_sanctions_clear": True},
108
+ )
109
+
110
+ # signer_match: wallet-binding verdict
111
+ # kind: 'pass' | 'wallet_signer_mismatch' | 'wallet_auth_requires_wallet_signing'
112
+ # plus claimed_operator / signer_operator / expected_signer / actual_signer /
113
+ # linked_wallets / agent_instructions
114
+ match = result.get("signer_match")
115
+ if match and match.get("kind") == "wallet_signer_mismatch":
116
+ # signer wallet resolves to a different operator than the claimed address
117
+ ...
118
+
119
+ # signer_sanctions: OFAC SDN wallet-address verdict (discriminated union)
120
+ # {"status": "clear"} | {"sanctioned": True, "ofac_label", "sdn_uid", "listed_at"}
121
+ # | {"status": "unavailable"}
122
+ sanctions = result.get("signer_sanctions")
123
+ if sanctions and sanctions.get("sanctioned"):
124
+ print("OFAC hit:", sanctions["ofac_label"], sanctions["sdn_uid"])
125
+ ```
126
+
127
+ 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.
128
+
129
+ 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.
130
+
99
131
  ### Credential Management
100
132
 
101
133
  ```python
@@ -218,7 +250,7 @@ if quota and quota["limit"] and quota["used"]:
218
250
  print(f"AgentScore quota at {pct:.1f}% — resets {quota['reset']}")
219
251
  ```
220
252
 
221
- `quota` is absent when the API doesn't emit the headers (Enterprise / unlimited tiers).
253
+ `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.
222
254
 
223
255
  ## Telemetry
224
256
 
@@ -73,6 +73,38 @@ client.create_session(operator_token="opc_...") # KYC refresh
73
73
 
74
74
  `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.
75
75
 
76
+ ### Server-side signer-match + sanctions screening
77
+
78
+ 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:
79
+
80
+ ```python
81
+ result = client.assess(
82
+ "0xclaimed...",
83
+ signer={"address": "0xsigner...", "network": "evm"},
84
+ policy={"require_sanctions_clear": True},
85
+ )
86
+
87
+ # signer_match: wallet-binding verdict
88
+ # kind: 'pass' | 'wallet_signer_mismatch' | 'wallet_auth_requires_wallet_signing'
89
+ # plus claimed_operator / signer_operator / expected_signer / actual_signer /
90
+ # linked_wallets / agent_instructions
91
+ match = result.get("signer_match")
92
+ if match and match.get("kind") == "wallet_signer_mismatch":
93
+ # signer wallet resolves to a different operator than the claimed address
94
+ ...
95
+
96
+ # signer_sanctions: OFAC SDN wallet-address verdict (discriminated union)
97
+ # {"status": "clear"} | {"sanctioned": True, "ofac_label", "sdn_uid", "listed_at"}
98
+ # | {"status": "unavailable"}
99
+ sanctions = result.get("signer_sanctions")
100
+ if sanctions and sanctions.get("sanctioned"):
101
+ print("OFAC hit:", sanctions["ofac_label"], sanctions["sdn_uid"])
102
+ ```
103
+
104
+ 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.
105
+
106
+ 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.
107
+
76
108
  ### Credential Management
77
109
 
78
110
  ```python
@@ -195,7 +227,7 @@ if quota and quota["limit"] and quota["used"]:
195
227
  print(f"AgentScore quota at {pct:.1f}% — resets {quota['reset']}")
196
228
  ```
197
229
 
198
- `quota` is absent when the API doesn't emit the headers (Enterprise / unlimited tiers).
230
+ `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.
199
231
 
200
232
  ## Telemetry
201
233
 
@@ -33,11 +33,12 @@ from agentscore.types import (
33
33
  Reputation,
34
34
  ReputationResponse,
35
35
  ReputationStatus,
36
- ResolveSigner,
37
36
  SessionCreateRequest,
38
37
  SessionCreateResponse,
39
38
  SessionPollResponse,
39
+ Signer,
40
40
  SignerMatch,
41
+ SignerSanctions,
41
42
  VerificationLevel,
42
43
  WalletAuthRequiresSigningBody,
43
44
  WalletSignerMismatchBody,
@@ -74,11 +75,12 @@ __all__ = [
74
75
  "Reputation",
75
76
  "ReputationResponse",
76
77
  "ReputationStatus",
77
- "ResolveSigner",
78
78
  "SessionCreateRequest",
79
79
  "SessionCreateResponse",
80
80
  "SessionPollResponse",
81
+ "Signer",
81
82
  "SignerMatch",
83
+ "SignerSanctions",
82
84
  "TimeoutError",
83
85
  "TokenExpiredError",
84
86
  "VerificationLevel",
@@ -137,9 +137,9 @@ if TYPE_CHECKING:
137
137
  DecisionPolicy,
138
138
  Network,
139
139
  ReputationResponse,
140
- ResolveSigner,
141
140
  SessionCreateResponse,
142
141
  SessionPollResponse,
142
+ Signer,
143
143
  )
144
144
 
145
145
 
@@ -255,13 +255,13 @@ class AgentScore:
255
255
  refresh: bool | None = None,
256
256
  policy: DecisionPolicy | None = None,
257
257
  operator_token: str | None = None,
258
- resolve_signer: ResolveSigner | None = None,
258
+ signer: Signer | None = None,
259
259
  ) -> AssessResponse:
260
260
  """Assess a wallet or operator (paid, writes score on-the-fly).
261
261
 
262
- ``resolve_signer`` opts into server-side wallet-signer-match: when supplied,
262
+ ``signer`` opts into server-side wallet-signer-match: when supplied,
263
263
  the API resolves the signer wallet against the claimed ``address`` and emits
264
- a ``signer_match`` block on the response. See :class:`ResolveSigner`.
264
+ a ``signer_match`` block on the response. See :class:`Signer`.
265
265
  """
266
266
  body: dict[str, Any] = {}
267
267
  if address:
@@ -274,8 +274,8 @@ class AgentScore:
274
274
  body["refresh"] = refresh
275
275
  if policy is not None:
276
276
  body["policy"] = dict(policy)
277
- if resolve_signer is not None:
278
- body["resolve_signer"] = dict(resolve_signer)
277
+ if signer is not None:
278
+ body["signer"] = dict(signer)
279
279
  client = self._get_sync_client()
280
280
  data, response = self._send_sync_with_response(lambda: client.post("/v1/assess", json=body))
281
281
  quota = _extract_quota(response)
@@ -393,11 +393,11 @@ class AgentScore:
393
393
  refresh: bool | None = None,
394
394
  policy: DecisionPolicy | None = None,
395
395
  operator_token: str | None = None,
396
- resolve_signer: ResolveSigner | None = None,
396
+ signer: Signer | None = None,
397
397
  ) -> AssessResponse:
398
398
  """Assess a wallet or operator (paid, writes score on-the-fly).
399
399
 
400
- ``resolve_signer`` opts into server-side wallet-signer-match — async mirror of
400
+ ``signer`` opts into server-side wallet-signer-match — async mirror of
401
401
  :meth:`assess`.
402
402
  """
403
403
  body: dict[str, Any] = {}
@@ -411,8 +411,8 @@ class AgentScore:
411
411
  body["refresh"] = refresh
412
412
  if policy is not None:
413
413
  body["policy"] = dict(policy)
414
- if resolve_signer is not None:
415
- body["resolve_signer"] = dict(resolve_signer)
414
+ if signer is not None:
415
+ body["signer"] = dict(signer)
416
416
  client = self._get_async_client()
417
417
  data, response = await self._send_async_with_response(lambda: client.post("/v1/assess", json=body))
418
418
  quota = _extract_quota(response)
@@ -507,9 +507,9 @@ class AgentScore:
507
507
  def telemetry_signer_match(self, payload: dict[str, Any]) -> None:
508
508
  """Fire-and-forget telemetry — report a wallet-signer-match verdict.
509
509
 
510
- Used internally by the commerce gate's ``verify_wallet_signer_match`` helper to track
511
- aggregate signer-binding behavior across merchants. Does not raise; failures are
512
- logged at warning level so persistent telemetry outages are visible in ops logs.
510
+ Tracks aggregate signer-binding behavior across merchants. Does not raise;
511
+ failures are logged at warning level so persistent telemetry outages are visible
512
+ in ops logs.
513
513
  """
514
514
  try:
515
515
  client = self._get_sync_client()
@@ -169,7 +169,7 @@ class DecisionPolicy(TypedDict, total=False):
169
169
  allowed_jurisdictions: list[str]
170
170
 
171
171
 
172
- class ResolveSigner(TypedDict):
172
+ class Signer(TypedDict):
173
173
  """Server-side wallet-signer-match request.
174
174
 
175
175
  When passed to ``assess()`` / ``aassess()``, the API resolves this signer wallet
@@ -187,15 +187,15 @@ class ResolveSigner(TypedDict):
187
187
  network: Literal["evm", "solana"]
188
188
 
189
189
 
190
- class SignerMatch(TypedDict, total=False):
190
+ class SignerMatch(TypedDict):
191
191
  """Server-side wallet-signer-match verdict.
192
192
 
193
193
  Emitted on ``AssessResponse.signer_match`` when the request supplied
194
- ``resolve_signer``. Mirrors the verdict shape commerce SDK gates produce locally;
194
+ ``signer``. Mirrors the verdict shape commerce SDK gates produce locally;
195
195
  SDK consumers spread this into 403 bodies verbatim instead of re-deriving via 2
196
196
  extra ``/v1/assess`` round trips.
197
197
 
198
- Fields populated depend on ``kind``.
198
+ ``kind`` is always present; other fields depend on which kind was emitted.
199
199
  """
200
200
 
201
201
  # ``pass`` — claimed wallet and signer wallet resolve to the same operator (or are
@@ -204,23 +204,65 @@ class SignerMatch(TypedDict, total=False):
204
204
  # has no wallet signer); agent should switch to operator_token auth.
205
205
  kind: Literal["pass", "wallet_signer_mismatch", "wallet_auth_requires_wallet_signing"]
206
206
  # Operator the claimed wallet resolves to. ``None`` if unlinked.
207
- claimed_operator: str | None
207
+ claimed_operator: NotRequired[str | None]
208
208
  # Operator the signer wallet resolves to. ``None`` if unlinked.
209
- signer_operator: str | None
209
+ signer_operator: NotRequired[str | None]
210
210
  # Echoed only on ``wallet_auth_requires_wallet_signing`` — the claimed wallet from
211
211
  # the request. Helps agents construct the recovery message.
212
- claimed_wallet: str
212
+ claimed_wallet: NotRequired[str]
213
213
  # Echoed on ``wallet_signer_mismatch`` — the claimed wallet, normalized.
214
- expected_signer: str
214
+ expected_signer: NotRequired[str]
215
215
  # Echoed on ``wallet_signer_mismatch`` — the signer wallet, normalized.
216
- actual_signer: str
216
+ actual_signer: NotRequired[str]
217
217
  # Same-operator linked wallets the agent could re-sign from to satisfy the claim.
218
218
  # Mirrors the top-level ``linked_wallets`` deny-guard — omitted on ``deny`` verdicts.
219
- linked_wallets: list[str]
219
+ linked_wallets: NotRequired[list[str]]
220
220
  # JSON-encoded ``{action, steps, user_message}`` envelope for SDK denial bodies.
221
221
  # Authoritative copy lives server-side; SDK consumers spread this into their 403
222
222
  # body without re-parsing.
223
- agent_instructions: str
223
+ agent_instructions: NotRequired[str]
224
+
225
+
226
+ class SignerSanctionsClear(TypedDict):
227
+ """Server-side wallet-sanctions verdict — address NOT on the OFAC SDN list."""
228
+
229
+ status: Literal["clear"]
230
+
231
+
232
+ class SignerSanctionsHit(TypedDict):
233
+ """Server-side wallet-sanctions verdict — address IS on the OFAC SDN list.
234
+
235
+ Under ``policy.require_sanctions_clear``, this verdict flips the response
236
+ ``decision`` to ``deny`` with ``decision_reasons`` including ``sanctions_flagged``.
237
+ An :class:`SignerSanctionsUnavailable` verdict under the same policy yields
238
+ ``decision_reasons`` including ``sanctions_check_unavailable`` (fail-closed).
239
+ """
240
+
241
+ sanctioned: Literal[True]
242
+ # Raw OFAC Digital Currency Address label the hit was published under (``ETH``,
243
+ # ``XBT``, ``USDT``, ``SOL``, ...). Investigation-history metadata; the gate's
244
+ # enforcement axis is the format-classified family, not this label.
245
+ ofac_label: str
246
+ # SDN entry's Identity ID. Same ``sdn_uid`` may surface multiple addresses (one
247
+ # entity, multiple wallets); join key for audit.
248
+ sdn_uid: str
249
+ # ISO date OFAC initially designated the entity. ``None`` if upstream omits.
250
+ listed_at: str | None
251
+
252
+
253
+ class SignerSanctionsUnavailable(TypedDict):
254
+ """Server-side wallet-sanctions verdict — lookup itself failed.
255
+
256
+ Under ``policy.require_sanctions_clear``, the gate fail-closes — falsely allowing
257
+ a sanctioned settle is an OFAC strict-liability violation; falsely denying a clean
258
+ buyer is bad UX.
259
+ """
260
+
261
+ status: Literal["unavailable"]
262
+
263
+
264
+ # Discriminated union: branch on ``status`` (clear/unavailable) vs ``sanctioned`` (True).
265
+ SignerSanctions = SignerSanctionsClear | SignerSanctionsHit | SignerSanctionsUnavailable
224
266
 
225
267
 
226
268
  class _AssessResponseRequired(TypedDict):
@@ -264,8 +306,11 @@ class AssessResponse(_AssessResponseRequired, total=False):
264
306
  policy_result: PolicyResult | None
265
307
  explanation: NotRequired[list[PolicyExplanation]]
266
308
  # Server-side wallet-signer-match verdict, returned only when the request supplied
267
- # ``resolve_signer``. Empty otherwise.
309
+ # ``signer``. Empty otherwise.
268
310
  signer_match: NotRequired[SignerMatch]
311
+ # Server-side OFAC SDN wallet-address verdict, returned only when the request supplied
312
+ # ``signer``. Empty otherwise.
313
+ signer_sanctions: NotRequired[SignerSanctions]
269
314
  # Quota state for this account, captured from response headers on the success path.
270
315
  # Use to monitor approach-to-cap proactively (warn at 80%, alert at 95%) before 429.
271
316
  quota: NotRequired[QuotaInfo]
@@ -396,7 +441,8 @@ namespace with a different key scheme."""
396
441
 
397
442
 
398
443
  class AssociateWalletResponse(TypedDict):
399
- associated: bool
444
+ # Always ``True`` on 2xx; failures surface via :class:`AgentScoreError` instead.
445
+ associated: Literal[True]
400
446
  first_seen: bool
401
447
  deduped: NotRequired[bool]
402
448
  # Cross-merchant pattern hint. Emitted only on the first wallet capture (first_seen=True)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "agentscore-py"
7
- version = "2.1.2"
7
+ version = "2.3.0"
8
8
  description = "Python client for the AgentScore APIs"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -274,6 +274,134 @@ def test_assess_forwards_policy_in_body():
274
274
  assert body["policy"] == policy
275
275
 
276
276
 
277
+ # ---------------------------------------------------------------------------
278
+ # assess: signer (server-side wallet-signer-match + sanctions screening)
279
+ # ---------------------------------------------------------------------------
280
+
281
+
282
+ @respx.mock
283
+ def test_assess_forwards_signer_in_body():
284
+ """assess(signer={...}) opts into server-side wallet-signer-match + signer-sanctions."""
285
+ route = respx.post(f"{BASE_URL}/v1/assess").mock(return_value=httpx.Response(200, json=ASSESS_PAYLOAD))
286
+ client = AgentScore(api_key=API_KEY)
287
+ signer = {"address": "0xsigner000000000000000000000000000000abc1", "network": "evm"}
288
+ client.assess(ADDRESS, signer=signer)
289
+ body = json.loads(route.calls.last.request.content)
290
+ assert body["signer"] == signer
291
+
292
+
293
+ @respx.mock
294
+ def test_assess_signer_omitted_when_none():
295
+ route = respx.post(f"{BASE_URL}/v1/assess").mock(return_value=httpx.Response(200, json=ASSESS_PAYLOAD))
296
+ client = AgentScore(api_key=API_KEY)
297
+ client.assess(ADDRESS)
298
+ body = json.loads(route.calls.last.request.content)
299
+ assert "signer" not in body
300
+
301
+
302
+ @respx.mock
303
+ def test_assess_returns_signer_match_and_signer_sanctions():
304
+ """Response surface: signer_match (wallet-binding) + signer_sanctions (OFAC SDN) compose on the same call."""
305
+ payload = {
306
+ **ASSESS_PAYLOAD,
307
+ "signer_match": {
308
+ "kind": "wallet_signer_mismatch",
309
+ "claimed_operator": "op_claimed",
310
+ "signer_operator": "op_attacker",
311
+ "expected_signer": "0xclaimed",
312
+ "actual_signer": "0xattacker",
313
+ "linked_wallets": ["0xclaimed"],
314
+ "agent_instructions": '{"action":"resign_or_switch_to_operator_token","steps":[],"user_message":"x"}',
315
+ },
316
+ "signer_sanctions": {
317
+ "sanctioned": True,
318
+ "ofac_label": "ETH",
319
+ "sdn_uid": "19011",
320
+ "listed_at": "2019-09-13T07:00:00.000Z",
321
+ },
322
+ }
323
+ respx.post(f"{BASE_URL}/v1/assess").mock(return_value=httpx.Response(200, json=payload))
324
+ client = AgentScore(api_key=API_KEY)
325
+ result = client.assess(ADDRESS, signer={"address": "0xattacker", "network": "evm"})
326
+ assert result["signer_match"]["kind"] == "wallet_signer_mismatch"
327
+ assert result["signer_match"]["expected_signer"] == "0xclaimed"
328
+ assert result["signer_sanctions"]["sanctioned"] is True
329
+ assert result["signer_sanctions"]["ofac_label"] == "ETH"
330
+
331
+
332
+ @respx.mock
333
+ def test_assess_returns_signer_sanctions_clear():
334
+ payload = {**ASSESS_PAYLOAD, "signer_sanctions": {"status": "clear"}}
335
+ respx.post(f"{BASE_URL}/v1/assess").mock(return_value=httpx.Response(200, json=payload))
336
+ client = AgentScore(api_key=API_KEY)
337
+ result = client.assess(ADDRESS, signer={"address": "0xsigner", "network": "evm"})
338
+ assert result["signer_sanctions"]["status"] == "clear"
339
+
340
+
341
+ @respx.mock
342
+ def test_assess_returns_signer_sanctions_unavailable():
343
+ payload = {**ASSESS_PAYLOAD, "signer_sanctions": {"status": "unavailable"}}
344
+ respx.post(f"{BASE_URL}/v1/assess").mock(return_value=httpx.Response(200, json=payload))
345
+ client = AgentScore(api_key=API_KEY)
346
+ result = client.assess(ADDRESS, signer={"address": "0xsigner", "network": "evm"})
347
+ assert result["signer_sanctions"]["status"] == "unavailable"
348
+
349
+
350
+ @pytest.mark.asyncio
351
+ @respx.mock
352
+ async def test_aassess_forwards_signer_in_body():
353
+ route = respx.post(f"{BASE_URL}/v1/assess").mock(return_value=httpx.Response(200, json=ASSESS_PAYLOAD))
354
+ client = AgentScore(api_key=API_KEY)
355
+ signer = {"address": "0xsignerasync", "network": "solana"}
356
+ await client.aassess(ADDRESS, signer=signer)
357
+ body = json.loads(route.calls.last.request.content)
358
+ assert body["signer"] == signer
359
+ await client.aclose()
360
+
361
+
362
+ @respx.mock
363
+ def test_assess_signer_preserves_solana_case():
364
+ """Solana addresses are case-sensitive; signer must thread through unmodified."""
365
+ route = respx.post(f"{BASE_URL}/v1/assess").mock(return_value=httpx.Response(200, json=ASSESS_PAYLOAD))
366
+ client = AgentScore(api_key=API_KEY)
367
+ # Real-shape Solana base58 mixed case
368
+ sol = "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy"
369
+ client.assess(ADDRESS, signer={"address": sol, "network": "solana"})
370
+ body = json.loads(route.calls.last.request.content)
371
+ assert body["signer"]["address"] == sol # byte-equal, no lowercasing
372
+
373
+
374
+ @respx.mock
375
+ def test_assess_signer_address_null_forwarded():
376
+ """signer.address=None for rails with no wallet signer (Stripe SPT, card)."""
377
+ route = respx.post(f"{BASE_URL}/v1/assess").mock(return_value=httpx.Response(200, json=ASSESS_PAYLOAD))
378
+ client = AgentScore(api_key=API_KEY)
379
+ client.assess(ADDRESS, signer={"address": None, "network": "evm"})
380
+ body = json.loads(route.calls.last.request.content)
381
+ assert body["signer"] == {"address": None, "network": "evm"}
382
+
383
+
384
+ @respx.mock
385
+ def test_assess_signer_raises_token_expired_with_signer():
386
+ """signer + 401 token_expired: TokenExpiredError raised; signer was still sent."""
387
+ route = respx.post(f"{BASE_URL}/v1/assess").mock(
388
+ return_value=httpx.Response(
389
+ 401,
390
+ json={
391
+ "error": {"code": "token_expired", "message": "expired"},
392
+ "verify_url": "https://example/verify",
393
+ },
394
+ )
395
+ )
396
+ from agentscore.errors import TokenExpiredError
397
+
398
+ client = AgentScore(api_key=API_KEY)
399
+ with pytest.raises(TokenExpiredError):
400
+ client.assess(operator_token="opc_expired", signer={"address": "0xs", "network": "evm"})
401
+ body = json.loads(route.calls.last.request.content)
402
+ assert body["signer"] == {"address": "0xs", "network": "evm"}
403
+
404
+
277
405
  # ---------------------------------------------------------------------------
278
406
  # Async: aget_reputation
279
407
  # ---------------------------------------------------------------------------
@@ -4,7 +4,7 @@ requires-python = ">=3.11"
4
4
 
5
5
  [[package]]
6
6
  name = "agentscore-py"
7
- version = "2.1.2"
7
+ version = "2.3.0"
8
8
  source = { editable = "." }
9
9
  dependencies = [
10
10
  { name = "httpx" },
File without changes
File without changes
File without changes
File without changes