From 77f65ac53396cf4cab24cd8dcf086cee314c69ad Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Thu, 23 Apr 2026 18:38:04 -0700 Subject: [PATCH 01/30] =?UTF-8?q?feat:=201.9.0=20=E2=80=94=20new=20denial?= =?UTF-8?q?=20codes=20+=20agent=5Fmemory=20type=20(TEC-226/218/227)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror of node-sdk 1.9.0 type additions for python-sdk: - DenialCode Literal includes wallet_signer_mismatch, wallet_auth_requires_wallet_signing (TEC-226) + token_expired, token_revoked (TEC-218); existing codes preserved - NextStepsAction Literal includes send_existing_identity, mint_new_credential, use_operator_token, regenerate_payment_from_linked_wallet - WalletSignerMismatchBody, WalletAuthRequiresSigningBody TypedDicts for TEC-226 denial shapes - AgentMemoryHint + AgentMemoryIdentityPaths for TEC-227 - SessionCreateResponse gains optional agent_memory Pure type surface; no runtime behavior change. Bumps to 1.9.0. Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore/__init__.py | 12 ++++ agentscore/types.py | 131 ++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 2 +- tests/test_types.py | 108 +++++++++++++++++++++++++++++++++ 4 files changed, 251 insertions(+), 2 deletions(-) create mode 100644 tests/test_types.py diff --git a/agentscore/__init__.py b/agentscore/__init__.py index cf225ae..eecf90d 100644 --- a/agentscore/__init__.py +++ b/agentscore/__init__.py @@ -3,15 +3,19 @@ from agentscore.client import AgentScore from agentscore.errors import AgentScoreError from agentscore.types import ( + AgentMemoryHint, + AgentMemoryIdentityPaths, AssessResponse, AssociateWalletResponse, CredentialCreateResponse, CredentialItem, CredentialListResponse, DecisionPolicy, + DenialCode, EntityType, Grade, Network, + NextStepsAction, OperatorVerification, Reputation, ReputationResponse, @@ -20,11 +24,15 @@ SessionCreateResponse, SessionPollResponse, VerificationLevel, + WalletAuthRequiresSigningBody, + WalletSignerMismatchBody, ) __version__ = _pkg_version("agentscore-py") __all__ = [ + "AgentMemoryHint", + "AgentMemoryIdentityPaths", "AgentScore", "AgentScoreError", "AssessResponse", @@ -33,9 +41,11 @@ "CredentialItem", "CredentialListResponse", "DecisionPolicy", + "DenialCode", "EntityType", "Grade", "Network", + "NextStepsAction", "OperatorVerification", "Reputation", "ReputationResponse", @@ -44,5 +54,7 @@ "SessionCreateResponse", "SessionPollResponse", "VerificationLevel", + "WalletAuthRequiresSigningBody", + "WalletSignerMismatchBody", "__version__", ] diff --git a/agentscore/types.py b/agentscore/types.py index 96852a5..a8e9f93 100644 --- a/agentscore/types.py +++ b/agentscore/types.py @@ -199,7 +199,7 @@ class SessionCreateRequest(TypedDict, total=False): product_name: str -class SessionCreateResponse(TypedDict): +class _SessionCreateResponseRequired(TypedDict): session_id: str poll_secret: str verify_url: str @@ -207,6 +207,11 @@ class SessionCreateResponse(TypedDict): expires_at: str +class SessionCreateResponse(_SessionCreateResponseRequired, total=False): + # Cross-merchant memory hint on first session creation (TEC-227). + agent_memory: AgentMemoryHint + + class SessionPollNextSteps(TypedDict, total=False): action: str user_message: str @@ -264,3 +269,127 @@ class AssociateWalletResponse(TypedDict): associated: bool first_seen: bool deduped: NotRequired[bool] + + +# --------------------------------------------------------------------------- +# Denial codes (TEC-226, TEC-218) — added in 1.9.0 +# --------------------------------------------------------------------------- + + +DenialCode = Literal[ + # Pre-1.9.0 + "operator_verification_required", + "compliance_denied", + "compliance_error", + "wallet_not_trusted", + "missing_identity", + "identity_verification_required", + "payment_required", + "api_error", + "kyc_required", + # Added in 1.9.0 (TEC-226) + "wallet_signer_mismatch", + "wallet_auth_requires_wallet_signing", + # Added in 1.9.0 (TEC-218) + "token_expired", + "token_revoked", +] +"""Denial codes returned by the gate in 403/402 error bodies. Additive — old codes retained for +backward compat. New codes in 1.9.0 let agents pick the right remediation without natural-language +parsing.""" + + +NextStepsAction = Literal[ + # Pre-1.9.0 + "poll_for_credential", + "contact_support", + "retry", + "retry_once_then_contact_support", + "regenerate_payment_credential", + "none", + "done", + # Added in 1.9.0 + "send_existing_identity", + "mint_new_credential", + "use_operator_token", + "regenerate_payment_from_linked_wallet", +] +"""Recommended agent action encoded in next_steps.action.""" + + +class AgentMemoryIdentityPaths(TypedDict): + wallet: str + operator_token: str + + +class AgentMemoryHint(TypedDict): + """Structured hint emitted on bootstrap denials and POST /v1/sessions success (TEC-227). + + LLM-hosted agents should save this to their long-term memory so future AgentScore encounters + skip the cold-start session flow. Contents are the pattern, not secrets — + do_not_persist_in_memory names bearer credentials that belong in a credential store. + """ + + save_for_future_agentscore_gates: bool + pattern_summary: str + quickstart: str + identity_check_endpoint: str + list_wallets_endpoint: NotRequired[str] + identity_paths: AgentMemoryIdentityPaths + bootstrap: str + do_not_persist_in_memory: list[str] + persist_in_credential_store: list[str] + + +class _WalletSignerMismatchNextSteps(TypedDict): + action: Literal["regenerate_payment_from_linked_wallet"] + + +class WalletSignerMismatchNextSteps(_WalletSignerMismatchNextSteps, total=False): + user_message: str + learn_more_url: str + + +class _WalletSignerMismatchBodyRequired(TypedDict): + error: dict # {"code": "wallet_signer_mismatch", "message": str} + claimed_operator: str + actual_signer_operator: str | None + linked_wallets: list[str] + next_steps: WalletSignerMismatchNextSteps + + +class WalletSignerMismatchBody(_WalletSignerMismatchBodyRequired, total=False): + """403 body for X-Wallet-Address + mismatched-signer rejections (TEC-226). + + Returned when the claimed wallet's operator doesn't match the payment signer's operator. + actual_signer_operator is None if the signer isn't linked to any operator. + """ + + expected_signer: str + actual_signer: str + agent_memory: AgentMemoryHint + + +class _WalletAuthRequiresSigningNextSteps(TypedDict): + action: Literal["use_operator_token"] + + +class WalletAuthRequiresSigningNextSteps(_WalletAuthRequiresSigningNextSteps, total=False): + user_message: str + signer_capable_rails: list[str] + learn_more_url: str + + +class _WalletAuthRequiresSigningBodyRequired(TypedDict): + error: dict # {"code": "wallet_auth_requires_wallet_signing", "message": str} + next_steps: WalletAuthRequiresSigningNextSteps + + +class WalletAuthRequiresSigningBody(_WalletAuthRequiresSigningBodyRequired, total=False): + """403 body for X-Wallet-Address + signer-less rail rejections (TEC-226). + + Returned when X-Wallet-Address is used with a payment rail that has no wallet signer + (SPT, card). Agent should switch to X-Operator-Token for those rails. + """ + + agent_memory: AgentMemoryHint diff --git a/pyproject.toml b/pyproject.toml index df65dd2..b9ce314 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "agentscore-py" -version = "1.8.0" +version = "1.9.0" description = "Python client for the AgentScore trust and reputation API" readme = "README.md" license = "MIT" diff --git a/tests/test_types.py b/tests/test_types.py new file mode 100644 index 0000000..1963ba7 --- /dev/null +++ b/tests/test_types.py @@ -0,0 +1,108 @@ +"""Type-presence checks for types introduced in 1.9.0 (TEC-226/218/227). + +The real assertion is that the file type-checks under ty; pytest just exercises +the runtime shape so CI has something to run. +""" + +from __future__ import annotations + +from typing import get_args + +from agentscore import ( + AgentMemoryHint, + AgentScore, + AgentScoreError, + DenialCode, + NextStepsAction, + SessionCreateResponse, + WalletAuthRequiresSigningBody, + WalletSignerMismatchBody, +) + + +def test_denial_code_includes_new_values() -> None: + codes = get_args(DenialCode) + assert "wallet_signer_mismatch" in codes + assert "wallet_auth_requires_wallet_signing" in codes + assert "token_expired" in codes + assert "token_revoked" in codes + # Backward-compat: pre-1.9.0 codes still present. + assert "operator_verification_required" in codes + assert "compliance_denied" in codes + + +def test_next_steps_action_includes_new_values() -> None: + actions = get_args(NextStepsAction) + assert "send_existing_identity" in actions + assert "mint_new_credential" in actions + assert "use_operator_token" in actions + assert "regenerate_payment_from_linked_wallet" in actions + + +def test_wallet_signer_mismatch_body_shape() -> None: + body: WalletSignerMismatchBody = { + "error": {"code": "wallet_signer_mismatch", "message": "signer does not match claimed wallet"}, + "claimed_operator": "op_abc", + "actual_signer_operator": None, + "expected_signer": "0x1111", + "actual_signer": "0x2222", + "linked_wallets": ["0x1111", "0x3333"], + "next_steps": {"action": "regenerate_payment_from_linked_wallet", "user_message": "sign with linked"}, + } + assert body["error"]["code"] == "wallet_signer_mismatch" + assert len(body["linked_wallets"]) == 2 + + +def test_wallet_auth_requires_signing_body_shape() -> None: + body: WalletAuthRequiresSigningBody = { + "error": {"code": "wallet_auth_requires_wallet_signing", "message": "SPT has no signer"}, + "next_steps": {"action": "use_operator_token", "signer_capable_rails": ["tempo", "x402"]}, + } + assert body["error"]["code"] == "wallet_auth_requires_wallet_signing" + assert "tempo" in body["next_steps"]["signer_capable_rails"] + + +def test_agent_memory_hint_shape() -> None: + memory: AgentMemoryHint = { + "save_for_future_agentscore_gates": True, + "pattern_summary": "AgentScore is a cross-merchant identity layer", + "quickstart": "https://docs.agentscore.sh/agent-commerce-quickstart", + "identity_check_endpoint": "https://api.agentscore.sh/v1/credentials", + "list_wallets_endpoint": "https://api.agentscore.sh/v1/credentials/wallets", + "identity_paths": { + "wallet": "send X-Wallet-Address when paying from a linked wallet", + "operator_token": "send X-Operator-Token for any rail", + }, + "bootstrap": "follow the session/verify flow if you have neither", + "do_not_persist_in_memory": ["operator_token", "poll_secret"], + "persist_in_credential_store": ["operator_token"], + } + assert memory["save_for_future_agentscore_gates"] is True + assert "X-Wallet-Address" in memory["identity_paths"]["wallet"] + + +def test_session_create_response_accepts_agent_memory() -> None: + res: SessionCreateResponse = { + "session_id": "sess_abc", + "poll_secret": "poll_abc", + "verify_url": "https://agentscore.sh/verify?session=sess_abc", + "poll_url": "https://api.agentscore.sh/v1/sessions/sess_abc", + "expires_at": "2026-04-24T00:00:00Z", + "agent_memory": { + "save_for_future_agentscore_gates": True, + "pattern_summary": "p", + "quickstart": "q", + "identity_check_endpoint": "e", + "identity_paths": {"wallet": "w", "operator_token": "ot"}, + "bootstrap": "b", + "do_not_persist_in_memory": [], + "persist_in_credential_store": [], + }, + } + assert res["agent_memory"]["save_for_future_agentscore_gates"] is True + + +def test_sdk_exports_new_symbols() -> None: + # Importability check — covered above via imports, but also guard the AgentScore class itself. + assert AgentScore is not None + assert AgentScoreError is not None From 02a0a4aece0e49194edd467c87a0d3f5712cd07b Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Thu, 23 Apr 2026 19:48:17 -0700 Subject: [PATCH 02/30] feat: AssessResponse.linked_wallets field (TEC-226 review-2 N1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror of node-sdk — python TypedDict gains optional linked_wallets. Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore/types.py | 4 ++++ uv.lock | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/agentscore/types.py b/agentscore/types.py index a8e9f93..0dba686 100644 --- a/agentscore/types.py +++ b/agentscore/types.py @@ -189,6 +189,10 @@ class PolicyExplanation(TypedDict, total=False): class AssessResponse(_AssessResponseRequired, total=False): operator_verification: OperatorVerification resolved_operator: str | None + # TEC-226: wallets linked to the same operator as the resolved identity. Populated on + # allow responses; omitted on denials to avoid leaking the linked set for flagged + # operators. Capped at 100 entries. + linked_wallets: list[str] verify_url: str policy_result: PolicyResult | None explanation: NotRequired[list[PolicyExplanation]] diff --git a/uv.lock b/uv.lock index f7ddb8a..e56d55d 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.11" [[package]] name = "agentscore-py" -version = "1.8.0" +version = "1.9.0" source = { editable = "." } dependencies = [ { name = "httpx" }, From 1229e95993f5c06b7f52aad7901e5d43de0dd4bd Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Thu, 23 Apr 2026 21:55:04 -0700 Subject: [PATCH 03/30] chore: review-cycle cleanup + CredentialCreateResponse parity fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Strip ticket IDs and version-introduction annotations from comments. - CredentialCreateResponse.expires_at is always present post-mint — mark it required (not `str | None`). Add optional `agent_memory` field so the type matches the API's first-credential emission behavior. Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore/types.py | 37 +++++++++++++++++++------------------ tests/test_types.py | 2 +- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/agentscore/types.py b/agentscore/types.py index 0dba686..7c8cc49 100644 --- a/agentscore/types.py +++ b/agentscore/types.py @@ -189,9 +189,9 @@ class PolicyExplanation(TypedDict, total=False): class AssessResponse(_AssessResponseRequired, total=False): operator_verification: OperatorVerification resolved_operator: str | None - # TEC-226: wallets linked to the same operator as the resolved identity. Populated on - # allow responses; omitted on denials to avoid leaking the linked set for flagged - # operators. Capped at 100 entries. + # Wallets linked to the same operator as the resolved identity. Populated on allow + # responses; omitted on denials to avoid leaking the linked set for flagged operators. + # Capped at 100 entries. linked_wallets: list[str] verify_url: str policy_result: PolicyResult | None @@ -212,7 +212,7 @@ class _SessionCreateResponseRequired(TypedDict): class SessionCreateResponse(_SessionCreateResponseRequired, total=False): - # Cross-merchant memory hint on first session creation (TEC-227). + # Cross-merchant memory hint on first session creation. agent_memory: AgentMemoryHint @@ -249,13 +249,17 @@ class CredentialItem(TypedDict): last_used_at: str | None -class CredentialCreateResponse(TypedDict): +class _CredentialCreateResponseRequired(TypedDict): id: str label: str | None credential: str prefix: str created_at: str - expires_at: str | None + expires_at: str + + +class CredentialCreateResponse(_CredentialCreateResponseRequired, total=False): + agent_memory: AgentMemoryHint class CredentialListResponse(TypedDict): @@ -276,12 +280,11 @@ class AssociateWalletResponse(TypedDict): # --------------------------------------------------------------------------- -# Denial codes (TEC-226, TEC-218) — added in 1.9.0 +# Denial codes # --------------------------------------------------------------------------- DenialCode = Literal[ - # Pre-1.9.0 "operator_verification_required", "compliance_denied", "compliance_error", @@ -291,20 +294,19 @@ class AssociateWalletResponse(TypedDict): "payment_required", "api_error", "kyc_required", - # Added in 1.9.0 (TEC-226) + # Wallet-signer binding — claimed X-Wallet-Address must resolve to the same operator + # as the payment signer; wallet-auth is rejected on rails with no wallet signer. "wallet_signer_mismatch", "wallet_auth_requires_wallet_signing", - # Added in 1.9.0 (TEC-218) + # Granular credential-state denials so agents know whether to retry, rotate, or re-KYC. "token_expired", "token_revoked", ] -"""Denial codes returned by the gate in 403/402 error bodies. Additive — old codes retained for -backward compat. New codes in 1.9.0 let agents pick the right remediation without natural-language -parsing.""" +"""Denial codes returned by the gate in 403/402 error bodies. Lets agents pick the right +remediation without natural-language parsing.""" NextStepsAction = Literal[ - # Pre-1.9.0 "poll_for_credential", "contact_support", "retry", @@ -312,7 +314,6 @@ class AssociateWalletResponse(TypedDict): "regenerate_payment_credential", "none", "done", - # Added in 1.9.0 "send_existing_identity", "mint_new_credential", "use_operator_token", @@ -327,7 +328,7 @@ class AgentMemoryIdentityPaths(TypedDict): class AgentMemoryHint(TypedDict): - """Structured hint emitted on bootstrap denials and POST /v1/sessions success (TEC-227). + """Structured hint emitted on bootstrap denials and POST /v1/sessions success. LLM-hosted agents should save this to their long-term memory so future AgentScore encounters skip the cold-start session flow. Contents are the pattern, not secrets — @@ -363,7 +364,7 @@ class _WalletSignerMismatchBodyRequired(TypedDict): class WalletSignerMismatchBody(_WalletSignerMismatchBodyRequired, total=False): - """403 body for X-Wallet-Address + mismatched-signer rejections (TEC-226). + """403 body for X-Wallet-Address + mismatched-signer rejections. Returned when the claimed wallet's operator doesn't match the payment signer's operator. actual_signer_operator is None if the signer isn't linked to any operator. @@ -390,7 +391,7 @@ class _WalletAuthRequiresSigningBodyRequired(TypedDict): class WalletAuthRequiresSigningBody(_WalletAuthRequiresSigningBodyRequired, total=False): - """403 body for X-Wallet-Address + signer-less rail rejections (TEC-226). + """403 body for X-Wallet-Address + signer-less rail rejections. Returned when X-Wallet-Address is used with a payment rail that has no wallet signer (SPT, card). Agent should switch to X-Operator-Token for those rails. diff --git a/tests/test_types.py b/tests/test_types.py index 1963ba7..0fc62bd 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -1,4 +1,4 @@ -"""Type-presence checks for types introduced in 1.9.0 (TEC-226/218/227). +"""Type-presence checks for denial codes, memory hints, and wallet-signer body types. The real assertion is that the file type-checks under ty; pytest just exercises the runtime shape so CI has something to run. From 62c0e1a4f73c2f8a3ee554c9b5d8d3ea5e2c6be7 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Thu, 23 Apr 2026 22:30:01 -0700 Subject: [PATCH 04/30] =?UTF-8?q?chore:=20raise=20coverage=20bar=2094=20?= =?UTF-8?q?=E2=86=92=2095=20(Tier=20A=20standard)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier A = published libraries on the critical path; uniform bar is 95. Actual coverage is already 98.14%, bump codifies the new floor. See MEMORY/feedback_coverage_standards.md for the full tier table. Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b9ce314..16d72a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ dev = [ ] [tool.pytest.ini_options] -addopts = "--cov=agentscore --cov-report=term-missing --cov-fail-under=94" +addopts = "--cov=agentscore --cov-report=term-missing --cov-fail-under=95" [tool.hatch.build.targets.wheel] packages = ["agentscore"] From 43a4b62d497df72aa54c8bd5aedd0d83ceed460c Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 24 Apr 2026 00:20:36 -0700 Subject: [PATCH 05/30] docs(claude): document wallet-auth response fields across methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Identity-model section now calls out linked_wallets[] + resolved_operator on assess responses, agent_memory on create_session and create_credential responses, and the next_steps.action enum on poll_session — so future sessions start with an accurate mental model of each method's response shape. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/CLAUDE.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 43d4136..be122b2 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -4,16 +4,18 @@ Python client for the AgentScore trust and reputation API. ## Identity Model +Two identity paths: `X-Wallet-Address` (wallet-based) and `X-Operator-Token` (credential-based). `assess` responses include `resolved_operator` and `linked_wallets[]` (same-operator sibling wallets — all resolve to the same canonical operator). `create_session` and `create_credential` responses include an `agent_memory` cross-merchant pattern hint. `create_session` also returns `next_steps.action="deliver_verify_url_and_poll"` + polling instructions. `poll_session` returns `next_steps.action` values: `continue_polling`, `retry_merchant_request_with_operator_token`, `use_stored_operator_token`, `create_new_session`, `verification_failed`, `contact_support`. + ## 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. -- `create_session` / `acreate_session` — create verification session -- `poll_session` / `apoll_session` — poll session status, returns credential when verified -- `create_credential` / `acreate_credential` — create operator credential (24h TTL default) +- `assess` / `aassess` — identity gate with policy (paid). Accepts `operator_token` for non-wallet agents. Response includes `linked_wallets[]` and `resolved_operator`. +- `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`. - `list_credentials` / `alist_credentials` — list active credentials - `revoke_credential` / `arevoke_credential` — revoke a credential -- `associate_wallet` / `aassociate_wallet` — report a signer wallet seen paying under a credential (TEC-189). Accepts optional `idempotency_key` (payment intent id / tx hash) so retries don't inflate transaction_count. +- `associate_wallet` / `aassociate_wallet` — report a signer wallet seen paying under a credential. Accepts optional `idempotency_key` (payment intent id / tx hash) so retries don't inflate transaction_count. ## Architecture From dadf015028e4b37ab9d808a6b24fccda3c867e87 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 24 Apr 2026 09:14:03 -0700 Subject: [PATCH 06/30] feat: add probe-strategy NextStepsAction values to the Literal Gate-emitted denials now ship these action codes inside agent_instructions: probe_identity_then_session, resign_or_switch_to_operator_token, switch_to_operator_token, and deliver_verify_url_and_poll (POST /v1/sessions). Add them to the NextStepsAction Literal plus the GET-poll session state actions so typed Python agents see recognized enum members. Drop the stale send_existing_identity (never implemented server-side; the current contract is probe_identity_then_session). Test: test_types.py asserts the new values are present in get_args output. Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore/types.py | 18 +++++++++++++++++- tests/test_types.py | 11 ++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/agentscore/types.py b/agentscore/types.py index 7c8cc49..46ef0a8 100644 --- a/agentscore/types.py +++ b/agentscore/types.py @@ -314,10 +314,26 @@ class AssociateWalletResponse(TypedDict): "regenerate_payment_credential", "none", "done", - "send_existing_identity", "mint_new_credential", "use_operator_token", "regenerate_payment_from_linked_wallet", + # Gate-emitted probe strategy — try wallet on signing rails, fall back to stored + # opc_..., fall back to session flow. Emitted on bare missing_identity 403s. + "probe_identity_then_session", + # Wallet signer mismatch — re-sign from expected_signer / any linked_wallets entry, + # or drop X-Wallet-Address and retry with X-Operator-Token. + "resign_or_switch_to_operator_token", + # Non-signing rail (Stripe SPT, card) — drop X-Wallet-Address, use X-Operator-Token. + "switch_to_operator_token", + # POST /v1/sessions success — deliver verify_url + poll poll_url. + "deliver_verify_url_and_poll", + # Session poll states. + "continue_polling", + "retry_merchant_request_with_operator_token", + "use_stored_operator_token", + "create_new_session", + "verification_failed", + "complete_kyc_then_retry", ] """Recommended agent action encoded in next_steps.action.""" diff --git a/tests/test_types.py b/tests/test_types.py index 0fc62bd..c1cbf5b 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -33,7 +33,16 @@ def test_denial_code_includes_new_values() -> None: def test_next_steps_action_includes_new_values() -> None: actions = get_args(NextStepsAction) - assert "send_existing_identity" in actions + # Probe strategy (gate-emitted missing_identity). + assert "probe_identity_then_session" in actions + assert "resign_or_switch_to_operator_token" in actions + assert "switch_to_operator_token" in actions + assert "deliver_verify_url_and_poll" in actions + # Session poll states. + assert "continue_polling" in actions + assert "retry_merchant_request_with_operator_token" in actions + assert "use_stored_operator_token" in actions + # Backward-compat: pre-1.9.0 actions still present. assert "mint_new_credential" in actions assert "use_operator_token" in actions assert "regenerate_payment_from_linked_wallet" in actions From d2a1d96a19c9295a7f4cd82112623faedbef64fe Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 24 Apr 2026 09:27:25 -0700 Subject: [PATCH 07/30] feat(types): CredentialRevokeResponse TypedDict parity with node-sdk revoke_credential and arevoke_credential were typed as -> dict, which node-sdk types as CredentialRevokeResponse ({ id, revoked: true }). Adds the TypedDict to agentscore/types.py, exports it from the public __init__.py, and tightens the return annotations. Type checkers (ty, mypy) can now enforce the response shape where previously they saw an opaque dict. Also enrich README with wallet resolution section (resolved_operator + linked_wallets) and mention that create_session responses carry structured next_steps + agent_memory. No runtime behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 7 ++++++- agentscore/__init__.py | 2 ++ agentscore/client.py | 5 +++-- agentscore/types.py | 5 +++++ 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index dcc72d2..83aacb4 100644 --- a/README.md +++ b/README.md @@ -52,17 +52,22 @@ print(result["decision"]) # "allow" | "deny" ### Verification Sessions -Bootstrap identity for first-time agents: +Bootstrap identity for first-time agents. The success body carries structured `next_steps` (with `action: "deliver_verify_url_and_poll"`) and a cross-merchant `agent_memory` hint. Poll responses carry `next_steps.action` from the typed `NextStepsAction` Literal (`continue_polling`, `retry_merchant_request_with_operator_token`, `use_stored_operator_token`, `create_new_session`, `verification_failed`, `contact_support`). ```python session = client.create_session() print(session["verify_url"], session["poll_url"], session["poll_secret"]) +print(session["next_steps"]["action"]) # "deliver_verify_url_and_poll" status = client.poll_session(session["session_id"], session["poll_secret"]) if status["status"] == "verified": print(status["operator_token"]) # "opc_..." — use for future requests ``` +### Wallet resolution + +`assess()` responses include `resolved_operator` and `linked_wallets` — all same-operator sibling wallets (claimed via SIWE or captured via prior `associate_wallet`). Merchants doing wallet-signer-match checks should accept a payment signed by any address in `linked_wallets`. + ### Credential Management ```python diff --git a/agentscore/__init__.py b/agentscore/__init__.py index eecf90d..1444d7b 100644 --- a/agentscore/__init__.py +++ b/agentscore/__init__.py @@ -10,6 +10,7 @@ CredentialCreateResponse, CredentialItem, CredentialListResponse, + CredentialRevokeResponse, DecisionPolicy, DenialCode, EntityType, @@ -40,6 +41,7 @@ "CredentialCreateResponse", "CredentialItem", "CredentialListResponse", + "CredentialRevokeResponse", "DecisionPolicy", "DenialCode", "EntityType", diff --git a/agentscore/client.py b/agentscore/client.py index 4fcac31..732b23a 100644 --- a/agentscore/client.py +++ b/agentscore/client.py @@ -20,6 +20,7 @@ AssociateWalletResponse, CredentialCreateResponse, CredentialListResponse, + CredentialRevokeResponse, DecisionPolicy, Network, ReputationResponse, @@ -185,7 +186,7 @@ def list_credentials(self) -> CredentialListResponse: response = client.get("/v1/credentials") return self._handle_response(response) - def revoke_credential(self, id: str) -> dict: + def revoke_credential(self, id: str) -> CredentialRevokeResponse: """Revoke an API credential by ID.""" client = self._get_sync_client() response = client.delete(f"/v1/credentials/{id}") @@ -306,7 +307,7 @@ async def alist_credentials(self) -> CredentialListResponse: response = await client.get("/v1/credentials") return self._handle_response(response) - async def arevoke_credential(self, id: str) -> dict: + async def arevoke_credential(self, id: str) -> CredentialRevokeResponse: """Revoke an API credential by ID.""" client = self._get_async_client() response = await client.delete(f"/v1/credentials/{id}") diff --git a/agentscore/types.py b/agentscore/types.py index 46ef0a8..ad8f39e 100644 --- a/agentscore/types.py +++ b/agentscore/types.py @@ -267,6 +267,11 @@ class CredentialListResponse(TypedDict): account_verification: NotRequired[dict] +class CredentialRevokeResponse(TypedDict): + id: str + revoked: Literal[True] + + Network = Literal["evm", "solana"] """Key-derivation family for associate_wallet. EVM covers any EVM chain (Base, Tempo, Ethereum, …) because EOA addresses derive from the same private key on every EVM chain. Solana lives in its own From b9db9533baf6fce43bb27aeec790bf8771cd3341 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 24 Apr 2026 09:54:38 -0700 Subject: [PATCH 08/30] feat(types): describe both gate-default and merchant-override denial shapes Mirror of node-sdk fix. WalletSignerMismatchBody and WalletAuthRequiresSigningBody required next_steps with a locked-literal action, which only covered the merchant-override shape. Gate-default merchants emit agent_instructions (JSON string) instead. Update both: - next_steps no longer required; action broadened from Literal to NextStepsAction. - Add optional agent_instructions: str. Also add SessionCreateNextSteps TypedDict and thread it through SessionCreateResponse so POST /v1/sessions' structured next_steps (action=deliver_verify_url_and_poll, poll_interval_seconds, steps) is visible in the typed response. Tighten SessionPollNextSteps.action from str to NextStepsAction. Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore/types.py | 52 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/agentscore/types.py b/agentscore/types.py index ad8f39e..679eff0 100644 --- a/agentscore/types.py +++ b/agentscore/types.py @@ -203,6 +203,20 @@ class SessionCreateRequest(TypedDict, total=False): product_name: str +class SessionCreateNextSteps(TypedDict, total=False): + """Structured action guidance on POST /v1/sessions success. + + action is always ``deliver_verify_url_and_poll`` — tells the agent to share verify_url + with the user and poll poll_url with X-Poll-Secret until an operator_token is issued. + """ + + action: NextStepsAction + poll_interval_seconds: int + poll_secret_header: str + steps: list[str] + user_message: str + + class _SessionCreateResponseRequired(TypedDict): session_id: str poll_secret: str @@ -212,12 +226,14 @@ class _SessionCreateResponseRequired(TypedDict): class SessionCreateResponse(_SessionCreateResponseRequired, total=False): + # Structured next_steps with action=deliver_verify_url_and_poll. + next_steps: SessionCreateNextSteps # Cross-merchant memory hint on first session creation. agent_memory: AgentMemoryHint class SessionPollNextSteps(TypedDict, total=False): - action: str + action: NextStepsAction user_message: str header_name: str poll_interval_seconds: int @@ -367,11 +383,11 @@ class AgentMemoryHint(TypedDict): persist_in_credential_store: list[str] -class _WalletSignerMismatchNextSteps(TypedDict): - action: Literal["regenerate_payment_from_linked_wallet"] +class _WalletSignerMismatchNextStepsRequired(TypedDict): + action: NextStepsAction -class WalletSignerMismatchNextSteps(_WalletSignerMismatchNextSteps, total=False): +class WalletSignerMismatchNextSteps(_WalletSignerMismatchNextStepsRequired, total=False): user_message: str learn_more_url: str @@ -381,7 +397,6 @@ class _WalletSignerMismatchBodyRequired(TypedDict): claimed_operator: str actual_signer_operator: str | None linked_wallets: list[str] - next_steps: WalletSignerMismatchNextSteps class WalletSignerMismatchBody(_WalletSignerMismatchBodyRequired, total=False): @@ -389,18 +404,30 @@ class WalletSignerMismatchBody(_WalletSignerMismatchBodyRequired, total=False): Returned when the claimed wallet's operator doesn't match the payment signer's operator. actual_signer_operator is None if the signer isn't linked to any operator. + + Action copy surfaces via one of two paths: + - ``agent_instructions``: JSON-encoded ``{action, steps, user_message}`` set by the + gate's default marshaller (action is typically ``resign_or_switch_to_operator_token``). + - ``next_steps``: structured object set by merchants who override the gate default + (action may be ``regenerate_payment_from_linked_wallet`` or any NextStepsAction). + + Agents should check for whichever is present. """ expected_signer: str actual_signer: str + agent_instructions: str + next_steps: WalletSignerMismatchNextSteps agent_memory: AgentMemoryHint -class _WalletAuthRequiresSigningNextSteps(TypedDict): - action: Literal["use_operator_token"] +class _WalletAuthRequiresSigningNextStepsRequired(TypedDict): + action: NextStepsAction -class WalletAuthRequiresSigningNextSteps(_WalletAuthRequiresSigningNextSteps, total=False): +class WalletAuthRequiresSigningNextSteps( + _WalletAuthRequiresSigningNextStepsRequired, total=False +): user_message: str signer_capable_rails: list[str] learn_more_url: str @@ -408,7 +435,6 @@ class WalletAuthRequiresSigningNextSteps(_WalletAuthRequiresSigningNextSteps, to class _WalletAuthRequiresSigningBodyRequired(TypedDict): error: dict # {"code": "wallet_auth_requires_wallet_signing", "message": str} - next_steps: WalletAuthRequiresSigningNextSteps class WalletAuthRequiresSigningBody(_WalletAuthRequiresSigningBodyRequired, total=False): @@ -416,6 +442,14 @@ class WalletAuthRequiresSigningBody(_WalletAuthRequiresSigningBodyRequired, tota Returned when X-Wallet-Address is used with a payment rail that has no wallet signer (SPT, card). Agent should switch to X-Operator-Token for those rails. + + Action copy surfaces via one of two paths: + - ``agent_instructions``: JSON-encoded ``{action, steps, user_message}`` set by the + gate's default marshaller (action is typically ``switch_to_operator_token``). + - ``next_steps``: structured object set by merchants who override the gate default + (action may be ``use_operator_token`` or any NextStepsAction). """ + agent_instructions: str + next_steps: WalletAuthRequiresSigningNextSteps agent_memory: AgentMemoryHint From 60eda6791acda673536ee718484fb1fde84d977b Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 24 Apr 2026 10:07:43 -0700 Subject: [PATCH 09/30] feat(types): close parity gaps vs node-sdk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four concrete divergences found in the symmetry audit, now closed: 1. AssociateWalletResponse.agent_memory — mirror of node-sdk fix. API emits agent_memory on first wallet capture; SDK now surfaces it as optional. 2. AssessResponse.identity_method typed as Literal["wallet", "operator_token"] — was loose str. Matches node-sdk's union. 3. AccountVerification TypedDict added and wired into CredentialListResponse.account_verification — was NotRequired[dict] with zero type safety. node-sdk had a typed AccountVerification interface; now python does too. 4. CredentialCreateErrorResponse added — typed 409 response for KYC-required credential mints. node-sdk had this; python was missing it. Exported from agentscore namespace. Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore/__init__.py | 4 ++++ agentscore/types.py | 36 ++++++++++++++++++++++++++++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/agentscore/__init__.py b/agentscore/__init__.py index 1444d7b..f349820 100644 --- a/agentscore/__init__.py +++ b/agentscore/__init__.py @@ -3,10 +3,12 @@ from agentscore.client import AgentScore from agentscore.errors import AgentScoreError from agentscore.types import ( + AccountVerification, AgentMemoryHint, AgentMemoryIdentityPaths, AssessResponse, AssociateWalletResponse, + CredentialCreateErrorResponse, CredentialCreateResponse, CredentialItem, CredentialListResponse, @@ -32,12 +34,14 @@ __version__ = _pkg_version("agentscore-py") __all__ = [ + "AccountVerification", "AgentMemoryHint", "AgentMemoryIdentityPaths", "AgentScore", "AgentScoreError", "AssessResponse", "AssociateWalletResponse", + "CredentialCreateErrorResponse", "CredentialCreateResponse", "CredentialItem", "CredentialListResponse", diff --git a/agentscore/types.py b/agentscore/types.py index 679eff0..9d016f4 100644 --- a/agentscore/types.py +++ b/agentscore/types.py @@ -172,7 +172,7 @@ class DecisionPolicy(TypedDict, total=False): class _AssessResponseRequired(TypedDict): decision: str | None decision_reasons: list[str] - identity_method: str + identity_method: Literal["wallet", "operator_token"] on_the_fly: bool updated_at: str | None @@ -278,9 +278,21 @@ class CredentialCreateResponse(_CredentialCreateResponseRequired, total=False): agent_memory: AgentMemoryHint +class AccountVerification(TypedDict, total=False): + """Account-level KYC state surfaced by GET /v1/credentials (same shape the API emits).""" + + kyc_status: str + kyc_verified_at: str | None + jurisdiction: str | None + age_verified: bool + age_bracket: str | None + sanctions_status: str | None + operator_type: str | None + + class CredentialListResponse(TypedDict): credentials: list[CredentialItem] - account_verification: NotRequired[dict] + account_verification: NotRequired[AccountVerification] class CredentialRevokeResponse(TypedDict): @@ -288,6 +300,22 @@ class CredentialRevokeResponse(TypedDict): revoked: Literal[True] +class _CredentialCreateErrorNextSteps(TypedDict): + action: NextStepsAction + + +class CredentialCreateErrorNextSteps(_CredentialCreateErrorNextSteps, total=False): + user_message: str + + +class CredentialCreateErrorResponse(TypedDict): + """409 response body when POST /v1/credentials is called before KYC completes.""" + + error: dict # {"code": "kyc_required", "message": str} + verify_url: str + next_steps: CredentialCreateErrorNextSteps + + Network = Literal["evm", "solana"] """Key-derivation family for associate_wallet. EVM covers any EVM chain (Base, Tempo, Ethereum, …) because EOA addresses derive from the same private key on every EVM chain. Solana lives in its own @@ -298,6 +326,10 @@ class AssociateWalletResponse(TypedDict): associated: bool first_seen: bool deduped: NotRequired[bool] + # Cross-merchant pattern hint. Emitted only on the first wallet capture (first_seen=True) + # so merchants can relay it once in a 402 body and LLM-hosted agents persist the pattern + # to long-term memory. Absent on all subsequent captures. + agent_memory: NotRequired[AgentMemoryHint] # --------------------------------------------------------------------------- From 6694328826cd8ead79ddf0bcbb83773fbd729c9b Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 24 Apr 2026 10:39:12 -0700 Subject: [PATCH 10/30] =?UTF-8?q?chore:=20homepage=20URL=20=E2=86=92=20age?= =?UTF-8?q?ntscore.sh=20(product=20landing,=20not=20docs=20subdomain)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 16d72a3..15ddb30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ classifiers = [ ] [project.urls] -Homepage = "https://docs.agentscore.sh" +Homepage = "https://agentscore.sh" Repository = "https://github.com/agentscore/python-sdk" Issues = "https://github.com/agentscore/python-sdk/issues" From 5925bf09f86bcb041e7f973c2c3f6be9ebcf7c81 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 24 Apr 2026 10:54:57 -0700 Subject: [PATCH 11/30] =?UTF-8?q?style:=20ruff=20format=20=E2=80=94=20iden?= =?UTF-8?q?tity=5Fmethod=20Literal=20line=20wrap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore/types.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/agentscore/types.py b/agentscore/types.py index 9d016f4..1fb3c9c 100644 --- a/agentscore/types.py +++ b/agentscore/types.py @@ -457,9 +457,7 @@ class _WalletAuthRequiresSigningNextStepsRequired(TypedDict): action: NextStepsAction -class WalletAuthRequiresSigningNextSteps( - _WalletAuthRequiresSigningNextStepsRequired, total=False -): +class WalletAuthRequiresSigningNextSteps(_WalletAuthRequiresSigningNextStepsRequired, total=False): user_message: str signer_capable_rails: list[str] learn_more_url: str From ec4ce16963655b97cb2472a9317f31830c86ea7b Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 24 Apr 2026 11:33:01 -0700 Subject: [PATCH 12/30] feat(types): drop token_revoked from DenialCode Literal Mirror of node-sdk. API unifies revoked + TTL-expired under token_expired to avoid leaking revoke intent. SDK Literal reflects the unified set. Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore/types.py | 5 +++-- tests/test_types.py | 1 - 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/agentscore/types.py b/agentscore/types.py index 1fb3c9c..2ca2167 100644 --- a/agentscore/types.py +++ b/agentscore/types.py @@ -351,9 +351,10 @@ class AssociateWalletResponse(TypedDict): # as the payment signer; wallet-auth is rejected on rails with no wallet signer. "wallet_signer_mismatch", "wallet_auth_requires_wallet_signing", - # Granular credential-state denials so agents know whether to retry, rotate, or re-KYC. + # Credential is no longer valid (revoked or past its TTL — the two cases share this + # code deliberately so the API doesn't leak which one). The 401 body carries an + # auto-minted session so agents recover without holding an API key. "token_expired", - "token_revoked", ] """Denial codes returned by the gate in 403/402 error bodies. Lets agents pick the right remediation without natural-language parsing.""" diff --git a/tests/test_types.py b/tests/test_types.py index c1cbf5b..7a983bf 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -25,7 +25,6 @@ def test_denial_code_includes_new_values() -> None: assert "wallet_signer_mismatch" in codes assert "wallet_auth_requires_wallet_signing" in codes assert "token_expired" in codes - assert "token_revoked" in codes # Backward-compat: pre-1.9.0 codes still present. assert "operator_verification_required" in codes assert "compliance_denied" in codes From a8057f5cfeab275c3d31aae9e3285d1557f1e623 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 24 Apr 2026 11:51:13 -0700 Subject: [PATCH 13/30] feat(types): drop mint_new_credential from NextStepsAction Literal Mirror of node-sdk. API no longer emits this action; token_expired now points to deliver_verify_url_and_poll (auto-session in 401 body). Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore/types.py | 1 - tests/test_types.py | 1 - 2 files changed, 2 deletions(-) diff --git a/agentscore/types.py b/agentscore/types.py index 2ca2167..02ce3d7 100644 --- a/agentscore/types.py +++ b/agentscore/types.py @@ -368,7 +368,6 @@ class AssociateWalletResponse(TypedDict): "regenerate_payment_credential", "none", "done", - "mint_new_credential", "use_operator_token", "regenerate_payment_from_linked_wallet", # Gate-emitted probe strategy — try wallet on signing rails, fall back to stored diff --git a/tests/test_types.py b/tests/test_types.py index 7a983bf..434c88b 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -42,7 +42,6 @@ def test_next_steps_action_includes_new_values() -> None: assert "retry_merchant_request_with_operator_token" in actions assert "use_stored_operator_token" in actions # Backward-compat: pre-1.9.0 actions still present. - assert "mint_new_credential" in actions assert "use_operator_token" in actions assert "regenerate_payment_from_linked_wallet" in actions From 56965400021e4c1a89460fffda71c32e0daa1e4a Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 24 Apr 2026 12:19:58 -0700 Subject: [PATCH 14/30] chore: bump ruff to 0.15.12 Co-Authored-By: Claude Opus 4.7 (1M context) --- uv.lock | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/uv.lock b/uv.lock index e56d55d..cba5f72 100644 --- a/uv.lock +++ b/uv.lock @@ -331,27 +331,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.11" +version = "0.15.12" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" } +sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" }, - { url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" }, - { url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" }, - { url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" }, - { url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" }, - { url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" }, - { url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" }, - { url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" }, - { url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" }, - { url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" }, - { url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" }, - { url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" }, - { url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" }, - { url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" }, - { url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" }, - { url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" }, - { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, + { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, + { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, + { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, + { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, + { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, ] [[package]] From c6986d65337a47d28ef5eb56cf0f1f0f8ea8a69c Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 24 Apr 2026 20:58:09 -0700 Subject: [PATCH 15/30] =?UTF-8?q?chore(ci):=20bump=20actions/cache=20v4?= =?UTF-8?q?=E2=86=92v5,=20osv-scanner=20v2.3.2=E2=86=92v2.3.5,=20setup-uv?= =?UTF-8?q?=20v7=E2=86=92v8.1.0,=20drop=20||=20true?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the cleanup landed in agentscore/pay (commit 8c3be79): - actions/cache@v5: Node 24 runtime, satisfied by GitHub-hosted + Blacksmith runners. - osv-scanner v2.3.5: 3 patch releases; switched to `scan source` subcommand syntax. - astral-sh/setup-uv@v8.1.0: pinned to exact tag because v8.x stops publishing floating major/minor tags as a supply-chain hardening measure (per the v8.0.0 release notes). - Removed `|| true` after osv-scanner and pip-audit. Verified locally every lockfile here returns 0 issues, so CI passes today. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 4 ++-- .github/workflows/publish.yml | 2 +- .github/workflows/security.yml | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec48ded..f66dab9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,9 +19,9 @@ jobs: timeout-minutes: 10 steps: - uses: useblacksmith/checkout@v1 - - uses: astral-sh/setup-uv@v7 + - uses: astral-sh/setup-uv@v8.1.0 - run: uv python install 3.12 - - uses: actions/cache@v4 + - uses: actions/cache@v5 with: path: | ~/.cache/uv diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ff15fd6..1843dc5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,7 +19,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: astral-sh/setup-uv@v7 + - uses: astral-sh/setup-uv@v8.1.0 - name: Set version from tag run: sed -i "s/^version = .*/version = \"${GITHUB_REF_NAME#v}\"/" pyproject.toml diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 2ff07f1..66d54f8 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -23,11 +23,11 @@ jobs: - name: Install osv-scanner run: | - curl -fsSL https://github.com/google/osv-scanner/releases/download/v2.3.2/osv-scanner_linux_amd64 -o osv-scanner + curl -fsSL https://github.com/google/osv-scanner/releases/download/v2.3.5/osv-scanner_linux_amd64 -o osv-scanner chmod +x osv-scanner - name: Scan dependencies - run: ./osv-scanner --lockfile=uv.lock --format=table || true + run: ./osv-scanner scan source --lockfile=uv.lock --format=table pip-audit: name: Python Audit @@ -35,7 +35,7 @@ jobs: timeout-minutes: 5 steps: - uses: useblacksmith/checkout@v1 - - uses: astral-sh/setup-uv@v7 + - uses: astral-sh/setup-uv@v8.1.0 - uses: actions/setup-python@v6 with: python-version: "3.13" @@ -46,5 +46,5 @@ jobs: - name: Audit dependencies run: | uv export --format requirements-txt --no-hashes > requirements.txt - pip-audit -r requirements.txt --disable-pip --no-deps || true + pip-audit -r requirements.txt --disable-pip --no-deps From ed922de16b5ed6541cc2eb60bd5a715ea9e56daa Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 24 Apr 2026 20:59:47 -0700 Subject: [PATCH 16/30] fix(ci): osv-scanner binary must be _linux_arm64 (Blacksmith runners are ARM) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous _linux_amd64 download silently exec-failed; the prior `|| true` masked it. Now that the swallow is removed, the architecture mismatch surfaces. Blacksmith pool is ARM, osv-scanner publishes both _amd64 and _arm64 binaries — switching to the right one. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/security.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 66d54f8..d1c9605 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -23,7 +23,7 @@ jobs: - name: Install osv-scanner run: | - curl -fsSL https://github.com/google/osv-scanner/releases/download/v2.3.5/osv-scanner_linux_amd64 -o osv-scanner + curl -fsSL https://github.com/google/osv-scanner/releases/download/v2.3.5/osv-scanner_linux_arm64 -o osv-scanner chmod +x osv-scanner - name: Scan dependencies From 25558f5ce0b64e16c5b7f3bd77f3ee43b7ca2ef6 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Sat, 25 Apr 2026 15:14:02 -0700 Subject: [PATCH 17/30] docs: note Solana wallet address support + linked_wallets may mix EVM/Solana --- .claude/CLAUDE.md | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index be122b2..4f6b44c 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -4,7 +4,7 @@ Python client for the AgentScore trust and reputation API. ## Identity Model -Two identity paths: `X-Wallet-Address` (wallet-based) and `X-Operator-Token` (credential-based). `assess` responses include `resolved_operator` and `linked_wallets[]` (same-operator sibling wallets — all resolve to the same canonical operator). `create_session` and `create_credential` responses include an `agent_memory` cross-merchant pattern hint. `create_session` also returns `next_steps.action="deliver_verify_url_and_poll"` + polling instructions. `poll_session` returns `next_steps.action` values: `continue_polling`, `retry_merchant_request_with_operator_token`, `use_stored_operator_token`, `create_new_session`, `verification_failed`, `contact_support`. +Two identity paths: `X-Wallet-Address` (wallet-based) and `X-Operator-Token` (credential-based). Wallet addresses accept both EVM (`0x...` 40-hex) and Solana (base58, 32–44 chars) formats — network is auto-detected from the address shape. `assess` responses include `resolved_operator` and `linked_wallets[]` (same-operator sibling wallets, normalized per network — EVM lowercased, Solana base58 verbatim; may mix chains for cross-chain operators). `create_session` and `create_credential` responses include an `agent_memory` cross-merchant pattern hint. `create_session` also returns `next_steps.action="deliver_verify_url_and_poll"` + polling instructions. `poll_session` returns `next_steps.action` values: `continue_polling`, `retry_merchant_request_with_operator_token`, `use_stored_operator_token`, `create_new_session`, `verification_failed`, `contact_support`. ## Methods (sync + async) diff --git a/README.md b/README.md index 83aacb4..c3dd9ae 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ if status["status"] == "verified": ### Wallet resolution -`assess()` responses include `resolved_operator` and `linked_wallets` — all same-operator sibling wallets (claimed via SIWE or captured via prior `associate_wallet`). Merchants doing wallet-signer-match checks should accept a payment signed by any address in `linked_wallets`. +`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. ### Credential Management From 3a052de1e554161e3948f883a766d3115cdff85b Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Sat, 25 Apr 2026 23:07:48 -0700 Subject: [PATCH 18/30] =?UTF-8?q?feat(client):=20retry=20once=20on=20429?= =?UTF-8?q?=20honoring=20retry-after=20=E2=80=94=20parity=20with=20node-sd?= =?UTF-8?q?k?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds _send_sync / _send_async wrappers that wrap every method (sync + async, 8 each). On 429 they sleep retry-after seconds (capped at 10s, default 1s) and re-issue the request once before raising. Matches node-sdk semantics so multi-language agents see the same rate-limit recovery behavior. Tests cover both paths: retry-then-success, retry-also-429-then-raise, default-wait-when-retry-after-missing, and the 10s cap on excessive values. Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore/client.py | 92 +++++++++++++++++++++---------------- tests/test_client.py | 106 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 38 deletions(-) diff --git a/agentscore/client.py b/agentscore/client.py index 732b23a..2f823e8 100644 --- a/agentscore/client.py +++ b/agentscore/client.py @@ -1,6 +1,8 @@ from __future__ import annotations +import asyncio import logging +import time from importlib.metadata import version as _pkg_version from typing import TYPE_CHECKING, Any @@ -10,11 +12,21 @@ logger = logging.getLogger("agentscore") -# Server truncates idempotency_key at 200 chars; warn the caller past that so two -# distinct payments that share the first 200 chars don't silently dedup. _IDEMPOTENCY_KEY_MAX = 200 +_MAX_RETRY_WAIT_SECONDS = 10.0 + + +def _retry_after_seconds(response: httpx.Response) -> float: + raw = response.headers.get("retry-after", "1") + try: + return min(float(raw), _MAX_RETRY_WAIT_SECONDS) + except (TypeError, ValueError): + return 1.0 + if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + from agentscore.types import ( AssessResponse, AssociateWalletResponse, @@ -74,6 +86,22 @@ def _get_async_client(self) -> httpx.AsyncClient: ) return self._async_client + def _send_sync(self, send_fn: Callable[[], httpx.Response]) -> Any: + """Issue a request, retry once on 429 honoring retry-after, then parse.""" + response = send_fn() + if response.status_code == 429: + time.sleep(_retry_after_seconds(response)) + response = send_fn() + return self._handle_response(response) + + async def _send_async(self, send_fn: Callable[[], Awaitable[httpx.Response]]) -> Any: + """Async variant of :meth:`_send_sync`.""" + response = await send_fn() + if response.status_code == 429: + await asyncio.sleep(_retry_after_seconds(response)) + response = await send_fn() + return self._handle_response(response) + def _handle_response(self, response: httpx.Response) -> Any: if response.status_code == 429: retry_after = response.headers.get("retry-after", "1") @@ -114,8 +142,7 @@ def get_reputation(self, address: str, chain: str | None = None) -> ReputationRe if chain: params["chain"] = chain client = self._get_sync_client() - response = client.get(f"/v1/reputation/{address}", params=params) - return self._handle_response(response) + return self._send_sync(lambda: client.get(f"/v1/reputation/{address}", params=params)) def assess( self, @@ -138,8 +165,7 @@ def assess( if policy is not None: body["policy"] = dict(policy) client = self._get_sync_client() - response = client.post("/v1/assess", json=body) - return self._handle_response(response) + return self._send_sync(lambda: client.post("/v1/assess", json=body)) def create_session( self, @@ -153,17 +179,17 @@ def create_session( if product_name is not None: body["product_name"] = product_name client = self._get_sync_client() - response = client.post("/v1/sessions", json=body) - return self._handle_response(response) + return self._send_sync(lambda: client.post("/v1/sessions", json=body)) def poll_session(self, session_id: str, poll_secret: str) -> SessionPollResponse: """Poll a session for its current status and result.""" client = self._get_sync_client() - response = client.get( - f"/v1/sessions/{session_id}", - headers={"X-Poll-Secret": poll_secret}, + return self._send_sync( + lambda: client.get( + f"/v1/sessions/{session_id}", + headers={"X-Poll-Secret": poll_secret}, + ), ) - return self._handle_response(response) def create_credential( self, @@ -177,20 +203,17 @@ def create_credential( if ttl_days is not None: body["ttl_days"] = ttl_days client = self._get_sync_client() - response = client.post("/v1/credentials", json=body) - return self._handle_response(response) + return self._send_sync(lambda: client.post("/v1/credentials", json=body)) def list_credentials(self) -> CredentialListResponse: """List all API credentials.""" client = self._get_sync_client() - response = client.get("/v1/credentials") - return self._handle_response(response) + return self._send_sync(lambda: client.get("/v1/credentials")) def revoke_credential(self, id: str) -> CredentialRevokeResponse: """Revoke an API credential by ID.""" client = self._get_sync_client() - response = client.delete(f"/v1/credentials/{id}") - return self._handle_response(response) + return self._send_sync(lambda: client.delete(f"/v1/credentials/{id}")) def associate_wallet( self, @@ -224,8 +247,7 @@ def associate_wallet( ) body["idempotency_key"] = idempotency_key client = self._get_sync_client() - response = client.post("/v1/credentials/wallets", json=body) - return self._handle_response(response) + return self._send_sync(lambda: client.post("/v1/credentials/wallets", json=body)) # --- Async methods --- @@ -235,8 +257,7 @@ async def aget_reputation(self, address: str, chain: str | None = None) -> Reput if chain: params["chain"] = chain client = self._get_async_client() - response = await client.get(f"/v1/reputation/{address}", params=params) - return self._handle_response(response) + return await self._send_async(lambda: client.get(f"/v1/reputation/{address}", params=params)) async def aassess( self, @@ -259,8 +280,7 @@ async def aassess( if policy is not None: body["policy"] = dict(policy) client = self._get_async_client() - response = await client.post("/v1/assess", json=body) - return self._handle_response(response) + return await self._send_async(lambda: client.post("/v1/assess", json=body)) async def acreate_session( self, @@ -274,17 +294,17 @@ async def acreate_session( if product_name is not None: body["product_name"] = product_name client = self._get_async_client() - response = await client.post("/v1/sessions", json=body) - return self._handle_response(response) + return await self._send_async(lambda: client.post("/v1/sessions", json=body)) async def apoll_session(self, session_id: str, poll_secret: str) -> SessionPollResponse: """Poll a session for its current status and result.""" client = self._get_async_client() - response = await client.get( - f"/v1/sessions/{session_id}", - headers={"X-Poll-Secret": poll_secret}, + return await self._send_async( + lambda: client.get( + f"/v1/sessions/{session_id}", + headers={"X-Poll-Secret": poll_secret}, + ), ) - return self._handle_response(response) async def acreate_credential( self, @@ -298,20 +318,17 @@ async def acreate_credential( if ttl_days is not None: body["ttl_days"] = ttl_days client = self._get_async_client() - response = await client.post("/v1/credentials", json=body) - return self._handle_response(response) + return await self._send_async(lambda: client.post("/v1/credentials", json=body)) async def alist_credentials(self) -> CredentialListResponse: """List all API credentials.""" client = self._get_async_client() - response = await client.get("/v1/credentials") - return self._handle_response(response) + return await self._send_async(lambda: client.get("/v1/credentials")) async def arevoke_credential(self, id: str) -> CredentialRevokeResponse: """Revoke an API credential by ID.""" client = self._get_async_client() - response = await client.delete(f"/v1/credentials/{id}") - return self._handle_response(response) + return await self._send_async(lambda: client.delete(f"/v1/credentials/{id}")) async def aassociate_wallet( self, @@ -336,8 +353,7 @@ async def aassociate_wallet( ) body["idempotency_key"] = idempotency_key client = self._get_async_client() - response = await client.post("/v1/credentials/wallets", json=body) - return self._handle_response(response) + return await self._send_async(lambda: client.post("/v1/credentials/wallets", json=body)) def close(self): if self._sync_client: diff --git a/tests/test_client.py b/tests/test_client.py index 3a81a7e..bbbb1b7 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1346,3 +1346,109 @@ async def test_aassociate_wallet_raises_on_402_payment_required(): assert exc_info.value.status_code == 402 assert exc_info.value.code == "payment_required" await client.aclose() + + +# --------------------------------------------------------------------------- +# 429 retry-once parity with node-sdk (sync + async) +# --------------------------------------------------------------------------- + + +@respx.mock +def test_assess_retries_once_on_429_then_succeeds(monkeypatch): + monkeypatch.setattr("agentscore.client.time.sleep", lambda _: None) + route = respx.post(f"{BASE_URL}/v1/assess").mock( + side_effect=[ + httpx.Response(429, headers={"retry-after": "0"}, json={}), + httpx.Response(200, json={"address": ADDRESS, "decision": "allow"}), + ], + ) + client = AgentScore(api_key=API_KEY) + result = client.assess(address=ADDRESS) + assert route.call_count == 2 + assert result["decision"] == "allow" + client.close() + + +@respx.mock +def test_assess_raises_when_429_persists_across_retry(monkeypatch): + monkeypatch.setattr("agentscore.client.time.sleep", lambda _: None) + route = respx.post(f"{BASE_URL}/v1/assess").mock( + return_value=httpx.Response(429, headers={"retry-after": "0"}, json={}), + ) + client = AgentScore(api_key=API_KEY) + with pytest.raises(AgentScoreError) as exc_info: + client.assess(address=ADDRESS) + assert route.call_count == 2 + assert exc_info.value.status_code == 429 + assert exc_info.value.code == "rate_limited" + client.close() + + +@respx.mock +def test_assess_uses_default_wait_when_retry_after_missing(monkeypatch): + captured: list[float] = [] + monkeypatch.setattr("agentscore.client.time.sleep", lambda s: captured.append(s)) + respx.post(f"{BASE_URL}/v1/assess").mock( + side_effect=[ + httpx.Response(429, json={}), + httpx.Response(200, json={"address": ADDRESS, "decision": "allow"}), + ], + ) + client = AgentScore(api_key=API_KEY) + client.assess(address=ADDRESS) + assert captured == [1.0] + client.close() + + +@respx.mock +def test_assess_caps_retry_wait_at_10_seconds(monkeypatch): + captured: list[float] = [] + monkeypatch.setattr("agentscore.client.time.sleep", lambda s: captured.append(s)) + respx.post(f"{BASE_URL}/v1/assess").mock( + side_effect=[ + httpx.Response(429, headers={"retry-after": "9999"}, json={}), + httpx.Response(200, json={"address": ADDRESS, "decision": "allow"}), + ], + ) + client = AgentScore(api_key=API_KEY) + client.assess(address=ADDRESS) + assert captured == [10.0] + client.close() + + +@pytest.mark.asyncio +@respx.mock +async def test_aassess_retries_once_on_429_then_succeeds(monkeypatch): + async def _no_sleep(_seconds: float) -> None: + return None + + monkeypatch.setattr("agentscore.client.asyncio.sleep", _no_sleep) + route = respx.post(f"{BASE_URL}/v1/assess").mock( + side_effect=[ + httpx.Response(429, headers={"retry-after": "0"}, json={}), + httpx.Response(200, json={"address": ADDRESS, "decision": "allow"}), + ], + ) + client = AgentScore(api_key=API_KEY) + result = await client.aassess(address=ADDRESS) + assert route.call_count == 2 + assert result["decision"] == "allow" + await client.aclose() + + +@pytest.mark.asyncio +@respx.mock +async def test_aassess_raises_when_429_persists_across_retry(monkeypatch): + async def _no_sleep(_seconds: float) -> None: + return None + + monkeypatch.setattr("agentscore.client.asyncio.sleep", _no_sleep) + route = respx.post(f"{BASE_URL}/v1/assess").mock( + return_value=httpx.Response(429, headers={"retry-after": "0"}, json={}), + ) + client = AgentScore(api_key=API_KEY) + with pytest.raises(AgentScoreError) as exc_info: + await client.aassess(address=ADDRESS) + assert route.call_count == 2 + assert exc_info.value.status_code == 429 + await client.aclose() From e82f1fb89c7da8064fd89e08ca7117ffcf5f1851 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Sun, 26 Apr 2026 00:38:20 -0700 Subject: [PATCH 19/30] feat(sdk): add verify_webhook_signature + AgentScoreError.status alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes audit items #12, #17. verify_webhook_signature: generic HMAC-SHA256 webhook signature verifier, Stripe-pattern (`t=,v1=` header). Useful both when AgentScore eventually ships outbound webhooks and as a generic helper for merchants verifying any HMAC-signed webhook source. Returns VerifyWebhookSignatureResult with `reason` set on failure (no_signatures / no_timestamp / timestamp_too_old / timestamp_in_future / signature_mismatch / malformed_header) so callers can differentiate transient vs permanent failures. Uses hmac.compare_digest for constant-time comparison. AgentScoreError.status property mirrors .status_code — polyglot codebases can use err.status regardless of which SDK raised the error. Both attributes return the same int. 11 new tests; coverage holds at 97.30% (Tier A). Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore/__init__.py | 3 + agentscore/errors.py | 8 +++ agentscore/webhooks.py | 127 +++++++++++++++++++++++++++++++++++++++ tests/test_webhooks.py | 131 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 269 insertions(+) create mode 100644 agentscore/webhooks.py create mode 100644 tests/test_webhooks.py diff --git a/agentscore/__init__.py b/agentscore/__init__.py index f349820..56091d1 100644 --- a/agentscore/__init__.py +++ b/agentscore/__init__.py @@ -30,6 +30,7 @@ WalletAuthRequiresSigningBody, WalletSignerMismatchBody, ) +from agentscore.webhooks import VerifyWebhookSignatureResult, verify_webhook_signature __version__ = _pkg_version("agentscore-py") @@ -60,7 +61,9 @@ "SessionCreateResponse", "SessionPollResponse", "VerificationLevel", + "VerifyWebhookSignatureResult", "WalletAuthRequiresSigningBody", "WalletSignerMismatchBody", "__version__", + "verify_webhook_signature", ] diff --git a/agentscore/errors.py b/agentscore/errors.py index 34d6aa8..a1a4118 100644 --- a/agentscore/errors.py +++ b/agentscore/errors.py @@ -3,3 +3,11 @@ def __init__(self, code: str, message: str, status_code: int): super().__init__(message) self.code = code self.status_code = status_code + + @property + def status(self) -> int: + """Alias for ``status_code`` — parity with node-sdk's attribute name. + + Polyglot codebases can use ``err.status`` regardless of which SDK raised the error. + """ + return self.status_code diff --git a/agentscore/webhooks.py b/agentscore/webhooks.py new file mode 100644 index 0000000..5bd4a9f --- /dev/null +++ b/agentscore/webhooks.py @@ -0,0 +1,127 @@ +"""Webhook signature verification — HMAC-SHA256, Stripe-pattern. + +Use this when AgentScore (or any service that signs outbound webhooks with this +convention) sends a webhook to your endpoint. Validates the +``X-AgentScore-Signature`` (or compatible) header before trusting the payload. + +Generic enough to cover any HMAC-signed webhook source: pass the right secret + header +name. Tolerant of multiple signature versions in the same header +(``t=...,v1=...`` style). +""" + +from __future__ import annotations + +import hashlib +import hmac +import time +from dataclasses import dataclass +from typing import Literal + + +@dataclass(frozen=True) +class VerifyWebhookSignatureResult: + """Result of :func:`verify_webhook_signature`.""" + + valid: bool + reason: Literal[ + "no_signatures", + "no_timestamp", + "timestamp_too_old", + "timestamp_in_future", + "signature_mismatch", + "malformed_header", + ] | None = None + + +def verify_webhook_signature( + payload: str | bytes, + signature_header: str, + secret: str, + tolerance_seconds: int = 300, + timestamp_key: str = "t", + signature_key: str = "v1", +) -> VerifyWebhookSignatureResult: + """Verify an HMAC-SHA256 signed webhook signature, Stripe-compatible. + + Header format: ``t=,v1=``. The signed payload is + ``f"{timestamp}.{raw_body}"``. Returns a result with ``reason`` set on failure so + callers can differentiate transient (timestamp drift) from permanent (mismatch). + + Args: + payload: Raw request body. MUST be the unparsed body — even one byte of + re-serialization breaks the signature. Capture before any JSON parse. + signature_header: Value of the signature header from the incoming request. + secret: Shared secret the sender uses to sign. + tolerance_seconds: Tolerance in seconds for timestamp-replay protection. + Default 300 (5 min) per Stripe convention. Set to 0 to disable. + timestamp_key: Override the timestamp parameter name. Default ``"t"``. + signature_key: Override the signature parameter name. Default ``"v1"``. + + Example:: + + from flask import request + from agentscore.webhooks import verify_webhook_signature + + @app.post("/webhooks/agentscore") + def handle_webhook(): + result = verify_webhook_signature( + payload=request.get_data(), # raw bytes — DO NOT parse JSON first + signature_header=request.headers.get("X-AgentScore-Signature", ""), + secret=os.environ["AGENTSCORE_WEBHOOK_SECRET"], + ) + if not result.valid: + return {"error": result.reason}, 400 + event = request.get_json(force=True) + # ... handle event ... + """ + parts = [p.strip() for p in signature_header.split(",") if p.strip()] + if not parts: + return VerifyWebhookSignatureResult(valid=False, reason="no_signatures") + + params: dict[str, list[str]] = {} + for p in parts: + if "=" not in p: + return VerifyWebhookSignatureResult(valid=False, reason="malformed_header") + key, _, value = p.partition("=") + params.setdefault(key, []).append(value) + + timestamp_str = params.get(timestamp_key, [None])[0] + if tolerance_seconds > 0: + if not timestamp_str: + return VerifyWebhookSignatureResult(valid=False, reason="no_timestamp") + try: + ts = int(timestamp_str) + except ValueError: + return VerifyWebhookSignatureResult(valid=False, reason="no_timestamp") + now_sec = int(time.time()) + if ts < now_sec - tolerance_seconds: + return VerifyWebhookSignatureResult(valid=False, reason="timestamp_too_old") + if ts > now_sec + tolerance_seconds: + return VerifyWebhookSignatureResult(valid=False, reason="timestamp_in_future") + + signatures = params.get(signature_key, []) + if not signatures: + return VerifyWebhookSignatureResult(valid=False, reason="no_signatures") + + payload_bytes = payload.encode("utf-8") if isinstance(payload, str) else payload + signed_payload = ( + f"{timestamp_str}.".encode() + payload_bytes if timestamp_str else payload_bytes + ) + + expected_hex = hmac.new(secret.encode("utf-8"), signed_payload, hashlib.sha256).hexdigest() + expected_bytes = bytes.fromhex(expected_hex) + + for sig_hex in signatures: + try: + actual_bytes = bytes.fromhex(sig_hex) + except ValueError: + continue + if len(actual_bytes) != len(expected_bytes): + continue + if hmac.compare_digest(actual_bytes, expected_bytes): + return VerifyWebhookSignatureResult(valid=True) + + return VerifyWebhookSignatureResult(valid=False, reason="signature_mismatch") + + +__all__ = ["VerifyWebhookSignatureResult", "verify_webhook_signature"] diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py new file mode 100644 index 0000000..cf15408 --- /dev/null +++ b/tests/test_webhooks.py @@ -0,0 +1,131 @@ +"""Tests for verify_webhook_signature.""" + +import hashlib +import hmac +import time + +from agentscore import verify_webhook_signature +from agentscore.errors import AgentScoreError + +SECRET = "whsec_testsecret" + + +def _sign(payload: str, ts: int, secret: str = SECRET) -> str: + signed = f"{ts}.{payload}".encode() + return hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest() + + +def test_accepts_valid_signature_with_current_timestamp(): + payload = '{"event":"test"}' + ts = int(time.time()) + sig = _sign(payload, ts) + result = verify_webhook_signature( + payload=payload, + signature_header=f"t={ts},v1={sig}", + secret=SECRET, + ) + assert result.valid is True + + +def test_accepts_bytes_payload(): + payload = b'{"event":"test"}' + ts = int(time.time()) + sig = _sign(payload.decode("utf-8"), ts) + result = verify_webhook_signature( + payload=payload, + signature_header=f"t={ts},v1={sig}", + secret=SECRET, + ) + assert result.valid is True + + +def test_rejects_timestamp_older_than_tolerance(): + payload = "{}" + ts = int(time.time()) - 600 + sig = _sign(payload, ts) + result = verify_webhook_signature( + payload=payload, + signature_header=f"t={ts},v1={sig}", + secret=SECRET, + tolerance_seconds=300, + ) + assert result.valid is False + assert result.reason == "timestamp_too_old" + + +def test_rejects_timestamp_in_future(): + payload = "{}" + ts = int(time.time()) + 600 + sig = _sign(payload, ts) + result = verify_webhook_signature( + payload=payload, + signature_header=f"t={ts},v1={sig}", + secret=SECRET, + ) + assert result.valid is False + assert result.reason == "timestamp_in_future" + + +def test_rejects_signature_mismatch(): + payload = "{}" + ts = int(time.time()) + sig = _sign(payload, ts, secret="wrong_secret") + result = verify_webhook_signature( + payload=payload, + signature_header=f"t={ts},v1={sig}", + secret=SECRET, + ) + assert result.valid is False + assert result.reason == "signature_mismatch" + + +def test_no_signatures_for_empty_header(): + result = verify_webhook_signature(payload="{}", signature_header="", secret=SECRET) + assert result.valid is False + assert result.reason == "no_signatures" + + +def test_malformed_header(): + result = verify_webhook_signature(payload="{}", signature_header="just_a_value", secret=SECRET) + assert result.valid is False + assert result.reason == "malformed_header" + + +def test_no_timestamp_when_missing_and_tolerance_positive(): + payload = "{}" + sig = hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest() + result = verify_webhook_signature(payload=payload, signature_header=f"v1={sig}", secret=SECRET) + assert result.valid is False + assert result.reason == "no_timestamp" + + +def test_tolerance_zero_skips_timestamp_check(): + payload = '{"event":"test"}' + sig = hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest() + result = verify_webhook_signature( + payload=payload, + signature_header=f"v1={sig}", + secret=SECRET, + tolerance_seconds=0, + ) + assert result.valid is True + + +def test_multiple_signatures_any_match(): + payload = "{}" + ts = int(time.time()) + sig_good = _sign(payload, ts) + sig_bad = hmac.new(b"wrong", f"{ts}.{payload}".encode(), hashlib.sha256).hexdigest() + result = verify_webhook_signature( + payload=payload, + signature_header=f"t={ts},v1={sig_bad},v1={sig_good}", + secret=SECRET, + ) + assert result.valid is True + + +def test_status_alias_matches_status_code(): + """AgentScoreError.status property mirrors .status_code (parity with node-sdk).""" + err = AgentScoreError(code="rate_limited", message="too many", status_code=429) + assert err.status == 429 + assert err.status == err.status_code From 51617ae22f1801059139d2fe01cc403a34f62886 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Sun, 26 Apr 2026 01:08:44 -0700 Subject: [PATCH 20/30] docs(readme): add verify_webhook_signature section + .status alias note Mirrors core/docs/integrations/python.mdx additions so README readers see the helper alongside the existing AgentScore API methods. Also documents the AgentScoreError.status property aliasing .status_code for polyglot codebase parity with node-sdk. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/README.md b/README.md index c3dd9ae..0165df3 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,24 @@ client.associate_wallet( ) ``` +### Verify webhook signatures + +For merchants who receive HMAC-signed webhooks (Stripe-pattern `t=,v1=` header): + +```python +from agentscore import verify_webhook_signature + +result = verify_webhook_signature( + payload=raw_request_body, # raw bytes — capture before any JSON parse + signature_header=request.headers.get("X-AgentScore-Signature", ""), + secret=os.environ["AGENTSCORE_WEBHOOK_SECRET"], +) +if not result.valid: + return {"error": result.reason}, 400 +``` + +`reason` distinguishes transient (`timestamp_too_old`, `timestamp_in_future`) from permanent (`signature_mismatch`, `no_signatures`, `malformed_header`) failures. Default tolerance 300s; pass `tolerance_seconds=0` to skip timestamp checking. Uses `hmac.compare_digest` for constant-time comparison. + ### Async All methods have async variants prefixed with `a`: @@ -129,6 +147,8 @@ with AgentScore(api_key="as_live_...") as client: | `timeout` | `10.0` | Request timeout (seconds)| | `user_agent` | `None` | Prepended to the default `User-Agent` as `"{user_agent} (agentscore-py/{version})"`. Use to attribute API calls to your app. | +`AgentScoreError.status` is a property aliasing `.status_code` so polyglot codebases can use the same attribute name regardless of which SDK raised the error. + ## Error Handling ```python From 70f3b47fa8f010cffd5716e432d3a959a032d082 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Sun, 26 Apr 2026 01:11:35 -0700 Subject: [PATCH 21/30] feat: add is_agentscore_test_address + AGENTSCORE_TEST_ADDRESSES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python parity with the test-mode helper currently shipping in @agent-score/pay and @agent-score/mcp. Recognizes the seven reserved AgentScore EVM test fixtures (0x0000…0001 through 0x0000…0007) so dev/test interactions can be labeled distinctly without burning real KYC credits. Lives in agentscore.test_mode and is re-exported from the package root. 6 new tests, coverage holds at 97.35%. Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore/__init__.py | 3 +++ agentscore/test_mode.py | 41 +++++++++++++++++++++++++++++++++++++++++ tests/test_test_mode.py | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+) create mode 100644 agentscore/test_mode.py create mode 100644 tests/test_test_mode.py diff --git a/agentscore/__init__.py b/agentscore/__init__.py index 56091d1..e4e5044 100644 --- a/agentscore/__init__.py +++ b/agentscore/__init__.py @@ -2,6 +2,7 @@ from agentscore.client import AgentScore from agentscore.errors import AgentScoreError +from agentscore.test_mode import AGENTSCORE_TEST_ADDRESSES, is_agentscore_test_address from agentscore.types import ( AccountVerification, AgentMemoryHint, @@ -35,6 +36,7 @@ __version__ = _pkg_version("agentscore-py") __all__ = [ + "AGENTSCORE_TEST_ADDRESSES", "AccountVerification", "AgentMemoryHint", "AgentMemoryIdentityPaths", @@ -65,5 +67,6 @@ "WalletAuthRequiresSigningBody", "WalletSignerMismatchBody", "__version__", + "is_agentscore_test_address", "verify_webhook_signature", ] diff --git a/agentscore/test_mode.py b/agentscore/test_mode.py new file mode 100644 index 0000000..f44760b --- /dev/null +++ b/agentscore/test_mode.py @@ -0,0 +1,41 @@ +"""Recognizers for AgentScore reserved test addresses. + +AgentScore's ``/v1/assess`` endpoint recognizes seven EVM addresses +(``0x0000…0001`` through ``0x0000…0007``) as test fixtures with deterministic +policy outcomes — KYC verified, sanctions clear, age gates passing — so dev/test +interactions don't burn real KYC credits and produce predictable results. + +Use this in test suites and dev/staging tooling to label test-mode interactions +distinctly from production traffic. +""" + +from __future__ import annotations + +_TEST_ADDRESSES: frozenset[str] = frozenset( + { + "0x0000000000000000000000000000000000000001", + "0x0000000000000000000000000000000000000002", + "0x0000000000000000000000000000000000000003", + "0x0000000000000000000000000000000000000004", + "0x0000000000000000000000000000000000000005", + "0x0000000000000000000000000000000000000006", + "0x0000000000000000000000000000000000000007", + }, +) + +AGENTSCORE_TEST_ADDRESSES: tuple[str, ...] = tuple(sorted(_TEST_ADDRESSES)) +"""The full list of reserved test addresses, exposed for documentation, completion, +and downstream test fixtures.""" + + +def is_agentscore_test_address(address: str | None) -> bool: + """Recognize one of the seven reserved AgentScore EVM test fixtures. + + Lowercases for comparison so accidentally mixed-case input still matches. + """ + if not address: + return False + return address.lower() in _TEST_ADDRESSES + + +__all__ = ["AGENTSCORE_TEST_ADDRESSES", "is_agentscore_test_address"] diff --git a/tests/test_test_mode.py b/tests/test_test_mode.py new file mode 100644 index 0000000..4c492b2 --- /dev/null +++ b/tests/test_test_mode.py @@ -0,0 +1,32 @@ +"""Tests for is_agentscore_test_address + AGENTSCORE_TEST_ADDRESSES.""" + +from agentscore import AGENTSCORE_TEST_ADDRESSES, is_agentscore_test_address + + +def test_returns_true_for_each_of_seven_reserved_addresses(): + for i in range(1, 8): + addr = "0x" + "0" * 39 + str(i) + assert is_agentscore_test_address(addr) is True + + +def test_matches_case_insensitively(): + assert is_agentscore_test_address("0x0000000000000000000000000000000000000001".upper()) is True + + +def test_returns_false_for_addresses_outside_reserved_range(): + assert is_agentscore_test_address("0x0000000000000000000000000000000000000008") is False + assert is_agentscore_test_address("0xabcabcabcabcabcabcabcabcabcabcabcabcabca") is False + + +def test_returns_false_for_none_and_empty(): + assert is_agentscore_test_address(None) is False + assert is_agentscore_test_address("") is False + + +def test_exports_exactly_seven_addresses(): + assert len(AGENTSCORE_TEST_ADDRESSES) == 7 + + +def test_every_exported_address_passes_recognizer(): + for addr in AGENTSCORE_TEST_ADDRESSES: + assert is_agentscore_test_address(addr) is True From e2ba1d666ba2e8da539096ebc773c103739764c4 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Sun, 26 Apr 2026 04:02:53 -0700 Subject: [PATCH 22/30] style: ruff format webhooks.py Run ruff format to satisfy CI's format-check step. Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore/webhooks.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/agentscore/webhooks.py b/agentscore/webhooks.py index 5bd4a9f..09d1efe 100644 --- a/agentscore/webhooks.py +++ b/agentscore/webhooks.py @@ -23,14 +23,17 @@ class VerifyWebhookSignatureResult: """Result of :func:`verify_webhook_signature`.""" valid: bool - reason: Literal[ - "no_signatures", - "no_timestamp", - "timestamp_too_old", - "timestamp_in_future", - "signature_mismatch", - "malformed_header", - ] | None = None + reason: ( + Literal[ + "no_signatures", + "no_timestamp", + "timestamp_too_old", + "timestamp_in_future", + "signature_mismatch", + "malformed_header", + ] + | None + ) = None def verify_webhook_signature( @@ -104,9 +107,7 @@ def handle_webhook(): return VerifyWebhookSignatureResult(valid=False, reason="no_signatures") payload_bytes = payload.encode("utf-8") if isinstance(payload, str) else payload - signed_payload = ( - f"{timestamp_str}.".encode() + payload_bytes if timestamp_str else payload_bytes - ) + signed_payload = f"{timestamp_str}.".encode() + payload_bytes if timestamp_str else payload_bytes expected_hex = hmac.new(secret.encode("utf-8"), signed_payload, hashlib.sha256).hexdigest() expected_bytes = bytes.fromhex(expected_hex) From 88775c229c8474a94fdae245f923d953e7e9f515 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Sun, 26 Apr 2026 22:54:56 -0700 Subject: [PATCH 23/30] chore(deps): add lefthook to dev group + document one-time setup uv has no equivalent of npm's \`prepare\` lifecycle, so contributors still need to run \`uv run lefthook install\` once after \`uv sync\`. Documented in CLAUDE.md alongside the other setup commands. Adding the PyPI wheel to dev deps means the binary is fetched as part of the standard sync. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/CLAUDE.md | 1 + pyproject.toml | 1 + uv.lock | 15 +++++++++++++++ 3 files changed, 17 insertions(+) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 4f6b44c..8de0479 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -39,6 +39,7 @@ Single-package Python library published to PyPI. ```bash uv sync --all-extras +uv run lefthook install # one-time per clone — wires pre-commit + pre-push uv run ruff check . uv run ruff format . uv run ty check agentscore/ diff --git a/pyproject.toml b/pyproject.toml index 15ddb30..8b2109b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ dev = [ "respx>=0.22.0", "pytest-asyncio>=1.2.0", "python-dotenv>=1.2.1", + "lefthook>=2.1.6", ] [tool.ty.src] diff --git a/uv.lock b/uv.lock index cba5f72..d4d9630 100644 --- a/uv.lock +++ b/uv.lock @@ -19,6 +19,7 @@ dev = [ [package.dev-dependencies] dev = [ + { name = "lefthook" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "python-dotenv" }, @@ -39,6 +40,7 @@ provides-extras = ["dev"] [package.metadata.requires-dev] dev = [ + { name = "lefthook", specifier = ">=2.1.6" }, { name = "pytest-asyncio", specifier = ">=1.2.0" }, { name = "pytest-cov", specifier = ">=6.0" }, { name = "python-dotenv", specifier = ">=1.2.1" }, @@ -238,6 +240,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "lefthook" +version = "2.1.6" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/1c/95addeea6b681f02cd44e40d8ce970973783f7b48081af88d3831c4f6da6/lefthook-2.1.6-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:6d4608d0bb9dbcf10d333132973941c21bc7cde31a328658611e2066eec26d7e", size = 5439518, upload-time = "2026-04-16T07:34:18.136Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d4/2c645051bed898f1ad377e7c2e611e0f857502ca76d052db0f8333bb668d/lefthook-2.1.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fa6395917dc510c2622f31b6d3992c5506ff2a7569cd9d321712ec29689229b3", size = 4964898, upload-time = "2026-04-16T07:34:13.497Z" }, + { url = "https://files.pythonhosted.org/packages/19/ea/15b0384c1227ad172af0c18f21f7e00c570ccce086af471ac32edc2cb507/lefthook-2.1.6-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:016295036dd1d94af7ea6604247f0c47102559fb12f97ffcfe7880e8d1ce7a1f", size = 4791370, upload-time = "2026-04-16T07:34:15.04Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c0/3d43c9e1a08fa79d53f0c6eb808fb0ae9becf29bc7bd89cd757470af040e/lefthook-2.1.6-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:6c00cd91d553e064a2f71c35d2497b4350d1efdf18dd8f2c7ce2b1d6e8e2e147", size = 5367784, upload-time = "2026-04-16T07:34:10.414Z" }, + { url = "https://files.pythonhosted.org/packages/e6/99/cad1e694989964a8a79b34fdd18e6642bc631e280c06a9341565b5861e3b/lefthook-2.1.6-py3-none-win_amd64.whl", hash = "sha256:e571c48a227f51e5b8aec117c550c6d2ad1f74034e87afd5b0c98cbef319ad3e", size = 5516604, upload-time = "2026-04-16T07:34:12.061Z" }, + { url = "https://files.pythonhosted.org/packages/85/e9/286657a2c7efbb8701380c4c8a08c54d7f58938a4610d660c463fa074ee3/lefthook-2.1.6-py3-none-win_arm64.whl", hash = "sha256:985b88d908067a6d48a0e89cbc20c18b6a68a3fc4d44387f8ac1c39b85f0f18b", size = 4867769, upload-time = "2026-04-16T07:34:16.757Z" }, +] + [[package]] name = "packaging" version = "26.1" From 3517955b9396e69787c9e76d91805126226870c2 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Sun, 26 Apr 2026 23:02:13 -0700 Subject: [PATCH 24/30] =?UTF-8?q?chore(deps):=20bump=20transitive=20packag?= =?UTF-8?q?ing=2026.1=20=E2=86=92=2026.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulled in via \`uv sync --upgrade\`. No direct deps changed; all production + dev deps were already at the latest in-range version. Tests + 97.35% coverage stay green. Co-Authored-By: Claude Opus 4.7 (1M context) --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index d4d9630..134bc4f 100644 --- a/uv.lock +++ b/uv.lock @@ -255,11 +255,11 @@ wheels = [ [[package]] name = "packaging" -version = "26.1" +version = "26.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] [[package]] From 6bae07fb26a77ecfbd49f55919ea8d16e6849521 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Mon, 27 Apr 2026 00:11:15 -0700 Subject: [PATCH 25/30] feat(sdk): preserve response-body fields on AgentScoreError + accept identity hints on create_session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python parity with the same node-sdk additions (commit 75ba394 in node-sdk). Two non-breaking surface extensions: 1. AgentScoreError grows a ``details: dict[str, Any]`` field populated from non-``error`` keys of the response body. Consumers can branch on ``verify_url``, ``linked_wallets``, ``claimed_operator``, ``actual_signer``, ``reasons``, etc. for granular denial recovery — previously the SDK dropped them and only surfaced ``code`` + ``message``. Defaults to ``{}`` so existing constructor calls keep working. 2. ``create_session`` / ``acreate_session`` accept optional ``address`` + ``operator_token`` so a session can be pre-associated with a known wallet or be a KYC refresh for an existing ``opc_...``. The ``/v1/sessions`` API has accepted these all along; the SDK was just not forwarding them. Coverage stays at 97.41% (Tier A bar 95%). 146 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore/client.py | 33 ++++++++++++++++++++--- agentscore/errors.py | 17 +++++++++++- tests/test_client.py | 63 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_errors.py | 20 ++++++++++++++ 4 files changed, 129 insertions(+), 4 deletions(-) diff --git a/agentscore/client.py b/agentscore/client.py index 2f823e8..5614da7 100644 --- a/agentscore/client.py +++ b/agentscore/client.py @@ -113,11 +113,16 @@ def _handle_response(self, response: httpx.Response) -> Any: if response.status_code >= 400: try: body = response.json() - error = body.get("error", {}) + error = body.get("error", {}) if isinstance(body, dict) else {} + # Preserve everything except the parsed `error` block so consumers + # can read verify_url, linked_wallets, reasons, etc. for granular + # denial recovery — mirrors node-sdk's AgentScoreError.details. + details = {k: v for k, v in body.items() if k != "error"} if isinstance(body, dict) else {} raise AgentScoreError( code=error.get("code", "unknown_error"), message=error.get("message", response.text), status_code=response.status_code, + details=details, ) except ValueError as err: raise AgentScoreError( @@ -171,13 +176,24 @@ def create_session( self, context: str | None = None, product_name: str | None = None, + address: str | None = None, + operator_token: str | None = None, ) -> SessionCreateResponse: - """Create an assessment session for deferred scoring.""" + """Create an assessment session for deferred scoring. + + ``address`` pre-associates the session with a known wallet (EVM ``0x...`` or + Solana base58). ``operator_token`` pre-associates with an existing ``opc_...`` — + e.g. refresh KYC for a credential. + """ body: dict[str, Any] = {} if context is not None: body["context"] = context if product_name is not None: body["product_name"] = product_name + if address is not None: + body["address"] = address + if operator_token is not None: + body["operator_token"] = operator_token client = self._get_sync_client() return self._send_sync(lambda: client.post("/v1/sessions", json=body)) @@ -286,13 +302,24 @@ async def acreate_session( self, context: str | None = None, product_name: str | None = None, + address: str | None = None, + operator_token: str | None = None, ) -> SessionCreateResponse: - """Create an assessment session for deferred scoring.""" + """Create an assessment session for deferred scoring. + + ``address`` pre-associates the session with a known wallet (EVM ``0x...`` or + Solana base58). ``operator_token`` pre-associates with an existing ``opc_...`` — + e.g. refresh KYC for a credential. + """ body: dict[str, Any] = {} if context is not None: body["context"] = context if product_name is not None: body["product_name"] = product_name + if address is not None: + body["address"] = address + if operator_token is not None: + body["operator_token"] = operator_token client = self._get_async_client() return await self._send_async(lambda: client.post("/v1/sessions", json=body)) diff --git a/agentscore/errors.py b/agentscore/errors.py index a1a4118..7796e28 100644 --- a/agentscore/errors.py +++ b/agentscore/errors.py @@ -1,8 +1,23 @@ +from typing import Any + + class AgentScoreError(Exception): - def __init__(self, code: str, message: str, status_code: int): + def __init__( + self, + code: str, + message: str, + status_code: int, + details: dict[str, Any] | None = None, + ): super().__init__(message) self.code = code self.status_code = status_code + # Response-body fields beyond `error.{code,message}` — e.g. verify_url, + # linked_wallets, claimed_operator, actual_signer, reasons. Consumers + # branch on these for granular recovery (see the mcp denial-code rendering + # in the node sibling for the canonical use). Defaults to {} so callers + # constructing this error by hand without a body can omit it. + self.details: dict[str, Any] = details or {} @property def status(self) -> int: diff --git a/tests/test_client.py b/tests/test_client.py index bbbb1b7..a00319c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -801,6 +801,57 @@ def test_create_session_raises_on_error(): assert exc_info.value.code == "bad_request" +@respx.mock +def test_create_session_forwards_address_and_operator_token(): + """Pre-association lets a session refresh KYC for an existing identity.""" + route = respx.post(f"{BASE_URL}/v1/sessions").mock(return_value=httpx.Response(200, json=SESSION_CREATE_PAYLOAD)) + client = AgentScore(api_key=API_KEY) + client.create_session(address="0xabc", operator_token="opc_xyz") + body = json.loads(route.calls.last.request.content) + assert body["address"] == "0xabc" + assert body["operator_token"] == "opc_xyz" + + +@respx.mock +def test_error_response_populates_details_with_non_error_fields(): + """Non-`error` response-body keys flow into AgentScoreError.details so consumers + can branch on verify_url, linked_wallets, claimed_operator, etc. for granular recovery.""" + respx.post(f"{BASE_URL}/v1/assess").mock( + return_value=httpx.Response( + 403, + json={ + "error": {"code": "wallet_signer_mismatch", "message": "Signer mismatch"}, + "claimed_operator": "op_abc", + "actual_signer": "0xdef", + "linked_wallets": ["0xabc", "0xdef"], + }, + ) + ) + client = AgentScore(api_key=API_KEY) + with pytest.raises(AgentScoreError) as exc_info: + client.assess(address="0xabc") + err = exc_info.value + assert err.code == "wallet_signer_mismatch" + assert err.details["claimed_operator"] == "op_abc" + assert err.details["actual_signer"] == "0xdef" + assert err.details["linked_wallets"] == ["0xabc", "0xdef"] + assert "error" not in err.details # the `error` key is parsed into code/message, not echoed + + +@respx.mock +def test_error_response_with_no_extra_fields_yields_empty_details(): + respx.post(f"{BASE_URL}/v1/assess").mock( + return_value=httpx.Response( + 500, + json={"error": {"code": "internal_error", "message": "Boom"}}, + ) + ) + client = AgentScore(api_key=API_KEY) + with pytest.raises(AgentScoreError) as exc_info: + client.assess(address="0xabc") + assert exc_info.value.details == {} + + # --------------------------------------------------------------------------- # poll_session # --------------------------------------------------------------------------- @@ -1047,6 +1098,18 @@ async def test_acreate_session_with_first_class_fields(): await client.aclose() +@pytest.mark.asyncio +@respx.mock +async def test_acreate_session_forwards_address_and_operator_token(): + route = respx.post(f"{BASE_URL}/v1/sessions").mock(return_value=httpx.Response(200, json=SESSION_CREATE_PAYLOAD)) + client = AgentScore(api_key=API_KEY) + await client.acreate_session(address="0xabc", operator_token="opc_xyz") + body = json.loads(route.calls.last.request.content) + assert body["address"] == "0xabc" + assert body["operator_token"] == "opc_xyz" + await client.aclose() + + # --------------------------------------------------------------------------- # Async: apoll_session # --------------------------------------------------------------------------- diff --git a/tests/test_errors.py b/tests/test_errors.py index 88acff0..ff24ce6 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -34,3 +34,23 @@ def test_unknown_error_code(): err = AgentScoreError(code="unknown_error", message="Something went wrong", status_code=500) assert err.code == "unknown_error" assert err.status_code == 500 + + +def test_details_defaults_to_empty_dict_when_omitted(): + err = AgentScoreError(code="not_found", message="Not found", status_code=404) + assert err.details == {} + + +def test_details_preserves_response_body_fields_for_granular_recovery(): + err = AgentScoreError( + code="wallet_signer_mismatch", + message="Signer mismatch", + status_code=403, + details={ + "claimed_operator": "op_abc", + "actual_signer": "0xdef", + "linked_wallets": ["0xabc", "0xdef"], + }, + ) + assert err.details["claimed_operator"] == "op_abc" + assert err.details["linked_wallets"] == ["0xabc", "0xdef"] From c9516462bbf6ebaae96aa607da5d8fc9d636b4c8 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Mon, 27 Apr 2026 00:24:09 -0700 Subject: [PATCH 26/30] =?UTF-8?q?feat(sdk)!:=20drop=20verify=5Fwebhook=5Fs?= =?UTF-8?q?ignature=20=E2=80=94=20AgentScore=20emits=20no=20webhooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the node-sdk drop. Removes the speculative HMAC-SHA256 webhook verifier: - agentscore/webhooks.py — deleted - tests/test_webhooks.py — deleted - agentscore/__init__.py — exports removed - README.md — webhook section scrubbed Same rationale as the node side (commit will reference the node-sdk hash): zero outbound webhook emitter in core/api, zero internal consumers, no API endpoint signs anything. The only inbound-webhook handler is the Stripe Identity flow in core/website (uses stripe SDK, not this lib). When AgentScore ships outbound events later, the right move is the official ``standardwebhooks`` PyPI lib (Svix interop spec) rather than re-rolling. Coverage stays at 98.16% (Tier A bar 95%). Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 18 ------ agentscore/__init__.py | 3 - agentscore/webhooks.py | 128 ---------------------------------------- tests/test_webhooks.py | 131 ----------------------------------------- 4 files changed, 280 deletions(-) delete mode 100644 agentscore/webhooks.py delete mode 100644 tests/test_webhooks.py diff --git a/README.md b/README.md index 0165df3..98e9faa 100644 --- a/README.md +++ b/README.md @@ -91,24 +91,6 @@ client.associate_wallet( ) ``` -### Verify webhook signatures - -For merchants who receive HMAC-signed webhooks (Stripe-pattern `t=,v1=` header): - -```python -from agentscore import verify_webhook_signature - -result = verify_webhook_signature( - payload=raw_request_body, # raw bytes — capture before any JSON parse - signature_header=request.headers.get("X-AgentScore-Signature", ""), - secret=os.environ["AGENTSCORE_WEBHOOK_SECRET"], -) -if not result.valid: - return {"error": result.reason}, 400 -``` - -`reason` distinguishes transient (`timestamp_too_old`, `timestamp_in_future`) from permanent (`signature_mismatch`, `no_signatures`, `malformed_header`) failures. Default tolerance 300s; pass `tolerance_seconds=0` to skip timestamp checking. Uses `hmac.compare_digest` for constant-time comparison. - ### Async All methods have async variants prefixed with `a`: diff --git a/agentscore/__init__.py b/agentscore/__init__.py index e4e5044..a8c77a0 100644 --- a/agentscore/__init__.py +++ b/agentscore/__init__.py @@ -31,7 +31,6 @@ WalletAuthRequiresSigningBody, WalletSignerMismatchBody, ) -from agentscore.webhooks import VerifyWebhookSignatureResult, verify_webhook_signature __version__ = _pkg_version("agentscore-py") @@ -63,10 +62,8 @@ "SessionCreateResponse", "SessionPollResponse", "VerificationLevel", - "VerifyWebhookSignatureResult", "WalletAuthRequiresSigningBody", "WalletSignerMismatchBody", "__version__", "is_agentscore_test_address", - "verify_webhook_signature", ] diff --git a/agentscore/webhooks.py b/agentscore/webhooks.py deleted file mode 100644 index 09d1efe..0000000 --- a/agentscore/webhooks.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Webhook signature verification — HMAC-SHA256, Stripe-pattern. - -Use this when AgentScore (or any service that signs outbound webhooks with this -convention) sends a webhook to your endpoint. Validates the -``X-AgentScore-Signature`` (or compatible) header before trusting the payload. - -Generic enough to cover any HMAC-signed webhook source: pass the right secret + header -name. Tolerant of multiple signature versions in the same header -(``t=...,v1=...`` style). -""" - -from __future__ import annotations - -import hashlib -import hmac -import time -from dataclasses import dataclass -from typing import Literal - - -@dataclass(frozen=True) -class VerifyWebhookSignatureResult: - """Result of :func:`verify_webhook_signature`.""" - - valid: bool - reason: ( - Literal[ - "no_signatures", - "no_timestamp", - "timestamp_too_old", - "timestamp_in_future", - "signature_mismatch", - "malformed_header", - ] - | None - ) = None - - -def verify_webhook_signature( - payload: str | bytes, - signature_header: str, - secret: str, - tolerance_seconds: int = 300, - timestamp_key: str = "t", - signature_key: str = "v1", -) -> VerifyWebhookSignatureResult: - """Verify an HMAC-SHA256 signed webhook signature, Stripe-compatible. - - Header format: ``t=,v1=``. The signed payload is - ``f"{timestamp}.{raw_body}"``. Returns a result with ``reason`` set on failure so - callers can differentiate transient (timestamp drift) from permanent (mismatch). - - Args: - payload: Raw request body. MUST be the unparsed body — even one byte of - re-serialization breaks the signature. Capture before any JSON parse. - signature_header: Value of the signature header from the incoming request. - secret: Shared secret the sender uses to sign. - tolerance_seconds: Tolerance in seconds for timestamp-replay protection. - Default 300 (5 min) per Stripe convention. Set to 0 to disable. - timestamp_key: Override the timestamp parameter name. Default ``"t"``. - signature_key: Override the signature parameter name. Default ``"v1"``. - - Example:: - - from flask import request - from agentscore.webhooks import verify_webhook_signature - - @app.post("/webhooks/agentscore") - def handle_webhook(): - result = verify_webhook_signature( - payload=request.get_data(), # raw bytes — DO NOT parse JSON first - signature_header=request.headers.get("X-AgentScore-Signature", ""), - secret=os.environ["AGENTSCORE_WEBHOOK_SECRET"], - ) - if not result.valid: - return {"error": result.reason}, 400 - event = request.get_json(force=True) - # ... handle event ... - """ - parts = [p.strip() for p in signature_header.split(",") if p.strip()] - if not parts: - return VerifyWebhookSignatureResult(valid=False, reason="no_signatures") - - params: dict[str, list[str]] = {} - for p in parts: - if "=" not in p: - return VerifyWebhookSignatureResult(valid=False, reason="malformed_header") - key, _, value = p.partition("=") - params.setdefault(key, []).append(value) - - timestamp_str = params.get(timestamp_key, [None])[0] - if tolerance_seconds > 0: - if not timestamp_str: - return VerifyWebhookSignatureResult(valid=False, reason="no_timestamp") - try: - ts = int(timestamp_str) - except ValueError: - return VerifyWebhookSignatureResult(valid=False, reason="no_timestamp") - now_sec = int(time.time()) - if ts < now_sec - tolerance_seconds: - return VerifyWebhookSignatureResult(valid=False, reason="timestamp_too_old") - if ts > now_sec + tolerance_seconds: - return VerifyWebhookSignatureResult(valid=False, reason="timestamp_in_future") - - signatures = params.get(signature_key, []) - if not signatures: - return VerifyWebhookSignatureResult(valid=False, reason="no_signatures") - - payload_bytes = payload.encode("utf-8") if isinstance(payload, str) else payload - signed_payload = f"{timestamp_str}.".encode() + payload_bytes if timestamp_str else payload_bytes - - expected_hex = hmac.new(secret.encode("utf-8"), signed_payload, hashlib.sha256).hexdigest() - expected_bytes = bytes.fromhex(expected_hex) - - for sig_hex in signatures: - try: - actual_bytes = bytes.fromhex(sig_hex) - except ValueError: - continue - if len(actual_bytes) != len(expected_bytes): - continue - if hmac.compare_digest(actual_bytes, expected_bytes): - return VerifyWebhookSignatureResult(valid=True) - - return VerifyWebhookSignatureResult(valid=False, reason="signature_mismatch") - - -__all__ = ["VerifyWebhookSignatureResult", "verify_webhook_signature"] diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py deleted file mode 100644 index cf15408..0000000 --- a/tests/test_webhooks.py +++ /dev/null @@ -1,131 +0,0 @@ -"""Tests for verify_webhook_signature.""" - -import hashlib -import hmac -import time - -from agentscore import verify_webhook_signature -from agentscore.errors import AgentScoreError - -SECRET = "whsec_testsecret" - - -def _sign(payload: str, ts: int, secret: str = SECRET) -> str: - signed = f"{ts}.{payload}".encode() - return hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest() - - -def test_accepts_valid_signature_with_current_timestamp(): - payload = '{"event":"test"}' - ts = int(time.time()) - sig = _sign(payload, ts) - result = verify_webhook_signature( - payload=payload, - signature_header=f"t={ts},v1={sig}", - secret=SECRET, - ) - assert result.valid is True - - -def test_accepts_bytes_payload(): - payload = b'{"event":"test"}' - ts = int(time.time()) - sig = _sign(payload.decode("utf-8"), ts) - result = verify_webhook_signature( - payload=payload, - signature_header=f"t={ts},v1={sig}", - secret=SECRET, - ) - assert result.valid is True - - -def test_rejects_timestamp_older_than_tolerance(): - payload = "{}" - ts = int(time.time()) - 600 - sig = _sign(payload, ts) - result = verify_webhook_signature( - payload=payload, - signature_header=f"t={ts},v1={sig}", - secret=SECRET, - tolerance_seconds=300, - ) - assert result.valid is False - assert result.reason == "timestamp_too_old" - - -def test_rejects_timestamp_in_future(): - payload = "{}" - ts = int(time.time()) + 600 - sig = _sign(payload, ts) - result = verify_webhook_signature( - payload=payload, - signature_header=f"t={ts},v1={sig}", - secret=SECRET, - ) - assert result.valid is False - assert result.reason == "timestamp_in_future" - - -def test_rejects_signature_mismatch(): - payload = "{}" - ts = int(time.time()) - sig = _sign(payload, ts, secret="wrong_secret") - result = verify_webhook_signature( - payload=payload, - signature_header=f"t={ts},v1={sig}", - secret=SECRET, - ) - assert result.valid is False - assert result.reason == "signature_mismatch" - - -def test_no_signatures_for_empty_header(): - result = verify_webhook_signature(payload="{}", signature_header="", secret=SECRET) - assert result.valid is False - assert result.reason == "no_signatures" - - -def test_malformed_header(): - result = verify_webhook_signature(payload="{}", signature_header="just_a_value", secret=SECRET) - assert result.valid is False - assert result.reason == "malformed_header" - - -def test_no_timestamp_when_missing_and_tolerance_positive(): - payload = "{}" - sig = hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest() - result = verify_webhook_signature(payload=payload, signature_header=f"v1={sig}", secret=SECRET) - assert result.valid is False - assert result.reason == "no_timestamp" - - -def test_tolerance_zero_skips_timestamp_check(): - payload = '{"event":"test"}' - sig = hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest() - result = verify_webhook_signature( - payload=payload, - signature_header=f"v1={sig}", - secret=SECRET, - tolerance_seconds=0, - ) - assert result.valid is True - - -def test_multiple_signatures_any_match(): - payload = "{}" - ts = int(time.time()) - sig_good = _sign(payload, ts) - sig_bad = hmac.new(b"wrong", f"{ts}.{payload}".encode(), hashlib.sha256).hexdigest() - result = verify_webhook_signature( - payload=payload, - signature_header=f"t={ts},v1={sig_bad},v1={sig_good}", - secret=SECRET, - ) - assert result.valid is True - - -def test_status_alias_matches_status_code(): - """AgentScoreError.status property mirrors .status_code (parity with node-sdk).""" - err = AgentScoreError(code="rate_limited", message="too many", status_code=429) - assert err.status == 429 - assert err.status == err.status_code From 8e9db86f2b872b0361475da480d700d5d45fe394 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Mon, 27 Apr 2026 00:51:46 -0700 Subject: [PATCH 27/30] docs(readme): document AgentScoreError.details + create_session identity hints Mirrors node-sdk doc additions. Both were already shipped in code (e.code + e.details, create_session(address=..., operator_token=...)) but missing from the README. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.md b/README.md index 98e9faa..bd9202e 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,11 @@ print(session["next_steps"]["action"]) # "deliver_verify_url_and_poll" status = client.poll_session(session["session_id"], session["poll_secret"]) if status["status"] == "verified": print(status["operator_token"]) # "opc_..." — use for future requests + +# Optional pre-association: attach the session to a known wallet or refresh KYC +# for an existing operator credential. +client.create_session(address="0x...") +client.create_session(operator_token="opc_...") # KYC refresh ``` ### Wallet resolution @@ -142,6 +147,18 @@ except AgentScoreError as e: print(e.code, e.status_code, str(e)) ``` +`AgentScoreError.details` carries the rest of the response body — `verify_url`, `linked_wallets`, `claimed_operator`, `actual_signer`, `expected_signer`, `reasons`, `agent_memory` — so callers can branch on granular denial codes without re-parsing: + +```python +try: + client.assess("0xabc...", policy={"require_kyc": True}) +except AgentScoreError as e: + if e.code == "wallet_signer_mismatch": + print("Re-sign from one of:", e.details.get("linked_wallets")) + elif e.code == "token_expired": + print("Verify at:", e.details.get("verify_url")) +``` + ## Documentation - [API Reference](https://docs.agentscore.sh) From 4416bea1edde5b6d172d64649a1bcb78fc797510 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Mon, 27 Apr 2026 01:27:33 -0700 Subject: [PATCH 28/30] chore(release): bump to 2.0.0 Parity with node-sdk@2.0.0. Same reasoning: wallet-auth-hardening line includes breaking removals (verify_webhook_signature gone), so 2.0.0 is honest semver. Backward-compat test comments scrubbed of the 1.9.0 reference. Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 2 +- tests/test_types.py | 4 ++-- uv.lock | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8b2109b..4853c67 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "agentscore-py" -version = "1.9.0" +version = "2.0.0" description = "Python client for the AgentScore trust and reputation API" readme = "README.md" license = "MIT" diff --git a/tests/test_types.py b/tests/test_types.py index 434c88b..0f7df7b 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -25,7 +25,7 @@ def test_denial_code_includes_new_values() -> None: assert "wallet_signer_mismatch" in codes assert "wallet_auth_requires_wallet_signing" in codes assert "token_expired" in codes - # Backward-compat: pre-1.9.0 codes still present. + # Backward-compat: prior codes still present. assert "operator_verification_required" in codes assert "compliance_denied" in codes @@ -41,7 +41,7 @@ def test_next_steps_action_includes_new_values() -> None: assert "continue_polling" in actions assert "retry_merchant_request_with_operator_token" in actions assert "use_stored_operator_token" in actions - # Backward-compat: pre-1.9.0 actions still present. + # Backward-compat: prior actions still present. assert "use_operator_token" in actions assert "regenerate_payment_from_linked_wallet" in actions diff --git a/uv.lock b/uv.lock index 134bc4f..ccc5b18 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.11" [[package]] name = "agentscore-py" -version = "1.9.0" +version = "2.0.0" source = { editable = "." } dependencies = [ { name = "httpx" }, From f5836c336d81b6a3bb57d30c0dc3f8db9850b720 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Mon, 27 Apr 2026 03:06:30 -0700 Subject: [PATCH 29/30] docs: drop "mirrors node-sdk" framing in client.py comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three comments referenced "mirrors node-sdk's behavior" / "mirrors node-sdk's AgentScoreError.details". Rephrased to describe the behavior on its own — python-sdk stands alone as the Python flavor. Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore/client.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/agentscore/client.py b/agentscore/client.py index 5614da7..c8d7876 100644 --- a/agentscore/client.py +++ b/agentscore/client.py @@ -116,7 +116,7 @@ def _handle_response(self, response: httpx.Response) -> Any: error = body.get("error", {}) if isinstance(body, dict) else {} # Preserve everything except the parsed `error` block so consumers # can read verify_url, linked_wallets, reasons, etc. for granular - # denial recovery — mirrors node-sdk's AgentScoreError.details. + # denial recovery — exposed via AgentScoreError.details. details = {k: v for k, v in body.items() if k != "error"} if isinstance(body, dict) else {} raise AgentScoreError( code=error.get("code", "unknown_error"), @@ -253,8 +253,8 @@ def associate_wallet( "wallet_address": wallet_address, "network": network, } - # Truthy check (not `is not None`) so empty strings don't ship a useless key — mirrors - # node-sdk's behavior of only forwarding when the key actually has content. + # Truthy check (not `is not None`) so empty strings don't ship a useless key — + # only forward when the key actually has content. if idempotency_key: if len(idempotency_key) > _IDEMPOTENCY_KEY_MAX: logger.warning( @@ -370,8 +370,8 @@ async def aassociate_wallet( "wallet_address": wallet_address, "network": network, } - # Truthy check (not `is not None`) so empty strings don't ship a useless key — mirrors - # node-sdk's behavior of only forwarding when the key actually has content. + # Truthy check (not `is not None`) so empty strings don't ship a useless key — + # only forward when the key actually has content. if idempotency_key: if len(idempotency_key) > _IDEMPOTENCY_KEY_MAX: logger.warning( From 24770a1c3c19eae3a2d67c894c6a0845fb732909 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Tue, 28 Apr 2026 19:42:57 -0700 Subject: [PATCH 30/30] =?UTF-8?q?chore:=20bump=20ty=200.0.32=E2=86=920.0.3?= =?UTF-8?q?3=20(Astral=20type=20checker=20patch)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- uv.lock | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/uv.lock b/uv.lock index ccc5b18..8926070 100644 --- a/uv.lock +++ b/uv.lock @@ -425,26 +425,26 @@ wheels = [ [[package]] name = "ty" -version = "0.0.32" +version = "0.0.33" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/85/7e/2aa791c9ae7b8cd5024cd4122e92267f664ca954cea3def3211919fa3c1f/ty-0.0.32.tar.gz", hash = "sha256:8743174c5f920f6700a4a0c9de140109189192ba16226884cd50095b43b8a45c", size = 5522294, upload-time = "2026-04-20T19:29:01.626Z" } +sdist = { url = "https://files.pythonhosted.org/packages/84/44/9478c50c266826c1bf30d1692e589755bffa8f1c0a3eb7af8a346c255991/ty-0.0.33.tar.gz", hash = "sha256:46d63bda07403322cb6c28ccfdd5536be916e13df725c29f7ccd0a21f06bd9e8", size = 5559373, upload-time = "2026-04-28T10:45:13.18Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/62/eb/1075dc6a49d7acbe2584ae4d5b410c41b1f177a5adcc567e09eca4c69000/ty-0.0.32-py3-none-linux_armv6l.whl", hash = "sha256:dacbc2f6cd698d488ae7436838ff929570455bf94bfa4d9fe57a630c552aff83", size = 10902959, upload-time = "2026-04-20T19:28:31.907Z" }, - { url = "https://files.pythonhosted.org/packages/33/d2/c35fc8bc66e98d1ee9b0f8ed319bf743e450e1f1e997574b178fab75670f/ty-0.0.32-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:914bbc4f605ce2a9e2a78982e28fae1d3359a169d141f9dc3b4c7749cd5eca81", size = 10726172, upload-time = "2026-04-20T19:28:44.765Z" }, - { url = "https://files.pythonhosted.org/packages/96/32/c827da3ca480456fb02d8cea68a2609273b6c220fea0be9a4c8d8470b86e/ty-0.0.32-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4787ac9fe1f86b1f3133f5c6732adbe2df5668b50c679ac6e2d98cd284da812f", size = 10163701, upload-time = "2026-04-20T19:28:27.005Z" }, - { url = "https://files.pythonhosted.org/packages/ba/9e/2734478fbdb90c160cb2813a3916a16a2af5c1e231f87d635f6131d781fb/ty-0.0.32-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8ea0a728af99fe40dd744cba6441a2404f80b7f4bde17aa6da393810af5ea57", size = 10656220, upload-time = "2026-04-20T19:29:03.814Z" }, - { url = "https://files.pythonhosted.org/packages/44/9f/0007da2d35e424debe7e9f86ffbc1ab7f60983cfbc5f0411324ab2de5292/ty-0.0.32-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2850561f9b018ae33d7e5bbfa0ac414d3c518513edcffe43877dc9801446b9c5", size = 10696086, upload-time = "2026-04-20T19:28:46.829Z" }, - { url = "https://files.pythonhosted.org/packages/3b/5e/ce5fd4ec803222ae3e69a76d2a2db2eed55e19f5b131702b9789ef45f93d/ty-0.0.32-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b5fa2fb3c614349ee211d36476b49d88c5ef79a687cdb91b2872ad023b94d2f8", size = 11184800, upload-time = "2026-04-20T19:28:42.57Z" }, - { url = "https://files.pythonhosted.org/packages/6c/46/ebcf67a5999421331214aac51a7464db42de2be15bbe929c612a3ed0b039/ty-0.0.32-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b89969307ab2417d41c9be8059dd79feea577234e1e10d35132f5495e0d42c6", size = 11718718, upload-time = "2026-04-20T19:28:36.433Z" }, - { url = "https://files.pythonhosted.org/packages/18/2c/2141c86ed0ce0962b45cefb658a95e734f59759d47f20afdcd9c732910a1/ty-0.0.32-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b59868ede9b1d69a088f0d695df52a0061f95fa7baa1d5e0dc6fc9cf06e1334", size = 11346369, upload-time = "2026-04-20T19:28:48.967Z" }, - { url = "https://files.pythonhosted.org/packages/7a/da/ed6f772339cf29bd9a46def9d6db5084689eb574ee4d150ff704224c1ed8/ty-0.0.32-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8300caf35345498e9b9b03e550bba03cee8f5f5f8ab4c83c3b1ff1b7403b7d3a", size = 11280714, upload-time = "2026-04-20T19:28:51.516Z" }, - { url = "https://files.pythonhosted.org/packages/da/9b/c6813987edf4816a40e0c8e408b555f97d3f267c7b3a1688c8bbdf65609c/ty-0.0.32-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:583c7094f4574b02f724db924f98b804d1387a0bd9405ecb5e078cc0f47fbcfb", size = 10638806, upload-time = "2026-04-20T19:28:29.651Z" }, - { url = "https://files.pythonhosted.org/packages/4e/d4/0cefcbd2ad0f3d51762ccf58e652ec7da146eb6ae34f87228f6254bbb8be/ty-0.0.32-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e44ebe1bb4143a5628bc4db67ac0dfebe14594af671e4ee66f6f2e983da56501", size = 10726106, upload-time = "2026-04-20T19:29:06.3Z" }, - { url = "https://files.pythonhosted.org/packages/32/ad/2c8a97f91f06311f4367400f7d13534bbda2522c73c99a3e4c0757dff9b8/ty-0.0.32-py3-none-musllinux_1_2_i686.whl", hash = "sha256:06f17ada3e069cba6148342ef88e9929156beca8473e8d4f101b68f66c75643e", size = 10872951, upload-time = "2026-04-20T19:28:34.077Z" }, - { url = "https://files.pythonhosted.org/packages/ba/68/42293f9248106dd51875120971a5cc6ea315c2c4dcfb8e59aa063aa0af26/ty-0.0.32-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e96e60fa556cec04f15d7ea62d2ceee5982bd389233e961ab9fd42304e278175", size = 11363334, upload-time = "2026-04-20T19:28:54.036Z" }, - { url = "https://files.pythonhosted.org/packages/df/92/be9abf4d3e589ad5023e2ea965b93e204ec856420d46adf73c5c36c04678/ty-0.0.32-py3-none-win32.whl", hash = "sha256:2ff2ebb4986b24aebcf1444db7db5ca41b36086040e95eea9f8fb851c11e805c", size = 10260689, upload-time = "2026-04-20T19:28:56.541Z" }, - { url = "https://files.pythonhosted.org/packages/14/61/dc86acea899349d2579cb8419aecedd83dc504d7d6a10df65eef546c8300/ty-0.0.32-py3-none-win_amd64.whl", hash = "sha256:ba7284a4a954b598c1b31500352b3ec1f89bff533825592b5958848226fdc7ee", size = 11255371, upload-time = "2026-04-20T19:28:39.917Z" }, - { url = "https://files.pythonhosted.org/packages/43/01/beffec56d71ca25b343ede63adb076456b5b3e211f1c066452a44cd120b3/ty-0.0.32-py3-none-win_arm64.whl", hash = "sha256:7e10aadbdbda989a7d567ee6a37f8b98d4d542e31e3b190a2879fd581f75d658", size = 10658087, upload-time = "2026-04-20T19:28:59.286Z" }, + { url = "https://files.pythonhosted.org/packages/e9/24/e287388c63a19191be26b32ff4dbd06029834068150ebe2532939bc4c851/ty-0.0.33-py3-none-linux_armv6l.whl", hash = "sha256:94d0a9d2234261a8911396d59e506b5923fe0971dbda43b9dcea287936887fcc", size = 11021308, upload-time = "2026-04-28T10:45:43.34Z" }, + { url = "https://files.pythonhosted.org/packages/00/ca/ba1eed819895bd239fba8ee35dfcd5fcb266c203b0914a17a59579096bb5/ty-0.0.33-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e4a2b5ba078f90de342f56b5f7979bb77c9b9b1d8625a041352ffc6ee93c4073", size = 10777272, upload-time = "2026-04-28T10:45:32.905Z" }, + { url = "https://files.pythonhosted.org/packages/25/a8/c3131d37b44b3fea1d6654a1c929a0cd0873822f77a90482b8ec28f6fbbd/ty-0.0.33-py3-none-macosx_11_0_arm64.whl", hash = "sha256:84ff5707825e9af9668d2bcf66975f93e520a63b524ab494e3a8265735be2563", size = 10201078, upload-time = "2026-04-28T10:45:23.374Z" }, + { url = "https://files.pythonhosted.org/packages/7b/db/d8e37ff0045810cc65e1ff36aa0da0a2253c05659787ac987df8a16c7897/ty-0.0.33-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e375285736f57886868e7af0b11c7b0ec5b6543fa15e7ad2a714fed9f077d4e0", size = 10732347, upload-time = "2026-04-28T10:45:21.444Z" }, + { url = "https://files.pythonhosted.org/packages/e0/1a/20e83a412506a918e4684fc67b567cf7cc13b105470b3428cb23c3d5aa13/ty-0.0.33-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5680f6350c3b4e46b8bff6d7bb132366ea239463d6cad4892725d06046e65464", size = 10808238, upload-time = "2026-04-28T10:45:38.565Z" }, + { url = "https://files.pythonhosted.org/packages/5d/4b/d0a39f4464dc6cb4cc2c159473ce216bd1846bfb684c0323a3cb36dce5c6/ty-0.0.33-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5535538bad8d0f7e62bcdff02197cdb30e41451d80b35d27e17d128f2e1dc5d", size = 11288348, upload-time = "2026-04-28T10:45:08.419Z" }, + { url = "https://files.pythonhosted.org/packages/35/7e/f1745e0f9583363d7a83d9a4990fc244f76ecc30840ddad83dc16a33c52d/ty-0.0.33-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:da196c42bbbc069e1e21e3e52107c061aa9660352dae57a41930690b56e2c02d", size = 11789907, upload-time = "2026-04-28T10:45:19.064Z" }, + { url = "https://files.pythonhosted.org/packages/a5/71/25f39f46a12d662859d45bc648555d0661044eb43db6b5648c9947487da9/ty-0.0.33-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9281672921ef6d4460e03146b5e6c18cb1a3e3a3b8a1a88f6f33226d05a469b7", size = 11500774, upload-time = "2026-04-28T10:45:48.012Z" }, + { url = "https://files.pythonhosted.org/packages/94/ec/136959ecbb7c71cb90537f5aea441c73f4ab24612868a6ecdc9d7444d32d/ty-0.0.33-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82c1b8f303f82da64e878108e764be3ecbcd7c9903ac0a7f7031614ed00b97ab", size = 11360314, upload-time = "2026-04-28T10:45:05.402Z" }, + { url = "https://files.pythonhosted.org/packages/cf/95/32809575c222f00beed498cb728e9290a0f5009f930025381bb7253b2206/ty-0.0.33-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:efe3af412c9ff67bce5fa37d0a2b0d8555c24072b145a5bac6c79637f1c83abe", size = 10707785, upload-time = "2026-04-28T10:45:10.836Z" }, + { url = "https://files.pythonhosted.org/packages/13/89/c8e9531f7aa4a093359e15fa32c8e1277fbbe90d16894d7c6032d29f4b34/ty-0.0.33-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:aeec29c91ea768601747da546c3efc20b72c2fb1bd52bcc786a5c6eeff51d27b", size = 10834987, upload-time = "2026-04-28T10:45:40.738Z" }, + { url = "https://files.pythonhosted.org/packages/31/16/9835fbcf5338af1a1917bd28fdb8a7193c210b83f243aa286fa9f79cb3ad/ty-0.0.33-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a535977c52bbb5f7e96b8b70a6ad375ad077f4a9ff2492508ea3816a2b403819", size = 10968968, upload-time = "2026-04-28T10:45:30.26Z" }, + { url = "https://files.pythonhosted.org/packages/36/69/64c76aabc1bc70c7f24b686cd93c3407f8ea430905e395f59bf9603ef571/ty-0.0.33-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1d732facf39fcb221ba279d469c5040d37883e964f123b1563888efd34818180", size = 11458077, upload-time = "2026-04-28T10:45:45.971Z" }, + { url = "https://files.pythonhosted.org/packages/91/84/fae27b0c4718776a298690d31ca4cc1995f2e3e1c63a7b59e84c41498e9a/ty-0.0.33-py3-none-win32.whl", hash = "sha256:d90960b574428dc252f85e8598ec5fcb7f619794196b2fc95a90da075ed4681c", size = 10345364, upload-time = "2026-04-28T10:45:16.836Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a0/a2938b23ae3e1a09a2d7c189e2ac5f7113676bae4e0e23948b568e18e5f8/ty-0.0.33-py3-none-win_amd64.whl", hash = "sha256:c1c3aec62c44de610c6e95f0a4e97ac3dbc07934bfdbf1fd90d758c9ff72f48e", size = 11342470, upload-time = "2026-04-28T10:45:26.455Z" }, + { url = "https://files.pythonhosted.org/packages/ab/62/7fb948aace38d2f6329261bb33c035a8484549c74f1db28649c7a4c6fed9/ty-0.0.33-py3-none-win_arm64.whl", hash = "sha256:0d44f99ba1b441e55e2aa301b2ac0a21112784931b46a5f66f4ea9efe5620d97", size = 10742673, upload-time = "2026-04-28T10:45:35.555Z" }, ] [[package]]