From a44bd4be01f9083be5eb2176e377d54a78b5824c Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Wed, 29 Apr 2026 18:47:39 -0700 Subject: [PATCH 1/8] fix: bootstrap fixable wallet_not_trusted denials into identity_verification_required MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixable compliance reasons (kyc_required, kyc_pending, kyc_failed, jurisdiction_required without explicit restriction) now get the same UX as missing_identity: the gate auto-mints a verification session, the agent polls until status=verified, gets a fresh opc_..., and retries with X-Operator-Token. Unfixable reasons (sanctions_flagged, age_insufficient, jurisdiction_restricted) keep the bare wallet_not_trusted denial — re-verification won't change the outcome, so the canonical agent_instructions action is now contact_support. Applied to all 6 framework adapters (fastapi, flask, django, aiohttp, sanic, middleware/ASGI) plus the shared response marshaller. Adds session-bootstrap test coverage (fixable → identity_verification_required, unfixable → bare wallet_not_trusted) for every adapter. OpenAPI denial-code description documents the re-route and the contact_support action. Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore_commerce/discovery/openapi.py | 22 +++- agentscore_commerce/identity/_response.py | 114 ++++++++++++++++++++- agentscore_commerce/identity/aiohttp.py | 16 +++ agentscore_commerce/identity/django.py | 15 +++ agentscore_commerce/identity/fastapi.py | 16 +++ agentscore_commerce/identity/flask.py | 77 +++++++------- agentscore_commerce/identity/middleware.py | 17 +++ agentscore_commerce/identity/sanic.py | 16 +++ pyproject.toml | 2 +- tests/test_aiohttp.py | 38 +++++++ tests/test_django.py | 54 ++++++++++ tests/test_fastapi.py | 59 +++++++++++ tests/test_flask.py | 56 +++++++++- tests/test_middleware.py | 36 ++++++- tests/test_response.py | 73 +++++++++++++ tests/test_sanic.py | 49 +++++++++ uv.lock | 2 +- 17 files changed, 608 insertions(+), 54 deletions(-) create mode 100644 tests/test_response.py diff --git a/agentscore_commerce/discovery/openapi.py b/agentscore_commerce/discovery/openapi.py index 8dd4a66..4b7235f 100644 --- a/agentscore_commerce/discovery/openapi.py +++ b/agentscore_commerce/discovery/openapi.py @@ -45,8 +45,14 @@ def agentscore_denial_schemas() -> dict[str, Any]: "payment_required", ], "description": ( - "Denial code emitted by AgentScore's gate middleware in 403 responses. Each comes with a " - "structured agent_instructions block describing recovery actions." + "Denial code emitted by AgentScore's gate middleware in 403 responses. Every code carries a " + "structured agent_instructions block describing recovery actions (per-code action: " + "missing_identity → probe_identity_then_session, identity_verification_required / " + "token_expired → deliver_verify_url_and_poll, invalid_credential → " + "switch_token_or_restart_session, wallet_signer_mismatch → resign_or_switch_to_operator_token, " + "wallet_auth_requires_wallet_signing → switch_to_operator_token, wallet_not_trusted → " + "contact_support — UNFIXABLE compliance only (sanctions/age/jurisdiction_restricted); " + "fixable reasons re-route to identity_verification_required, payment_required → contact_merchant)." ), }, "AgentScoreDenialBody": { @@ -56,14 +62,20 @@ def agentscore_denial_schemas() -> dict[str, Any]: "agent_instructions": { "type": "string", "description": ( - "JSON-encoded { action, steps, user_message } block. Agents parse this to learn how " - "to recover (e.g., poll a verify_url, switch headers, re-sign)." + "JSON-encoded { action, steps, user_message } block. Always present on every " + "denial; agents parse this to learn how to recover (e.g., poll verify_url, " + "switch headers, re-sign)." ), }, "verify_url": { "type": "string", "format": "uri", - "description": "Present for missing_identity / token_expired denials.", + "description": ( + "Present for missing_identity / identity_verification_required / token_expired " + "denials. Agent shares this with the user to complete KYC or claim a wallet. " + "Not present on wallet_not_trusted (UNFIXABLE compliance — re-verification " + "won't change the outcome)." + ), }, "session_id": {"type": "string"}, "poll_url": {"type": "string", "format": "uri"}, diff --git a/agentscore_commerce/identity/_response.py b/agentscore_commerce/identity/_response.py index 5e94a8e..c5a8631 100644 --- a/agentscore_commerce/identity/_response.py +++ b/agentscore_commerce/identity/_response.py @@ -125,6 +125,115 @@ } ) +WALLET_NOT_TRUSTED_INSTRUCTIONS = json.dumps( + { + "action": "contact_support", + "steps": [ + ( + "The wallet's operator failed an UNFIXABLE compliance check (sanctions, " + "age, or jurisdiction). `reasons` lists which: `sanctions_flagged` / " + "`age_insufficient` / `jurisdiction_restricted`. KYC re-verification " + "won't change the outcome — the policy denial is structural." + ), + ( + "Surface the denial to the user with the merchant's support contact. " + "Do not retry the same merchant request; do not hand the user a " + "verify_url (verification won't fix this code path)." + ), + ( + "Fixable compliance reasons (`kyc_required`, `kyc_pending`, " + "`kyc_failed`, `jurisdiction_required` without explicit restriction) " + "do NOT land on this code — the gate auto-mints a verification session " + "for those and returns `identity_verification_required` with poll " + "endpoints, same shape as `missing_identity`." + ), + ], + "user_message": ( + "This purchase is denied by the merchant's compliance policy and cannot be " + "resolved by re-verifying. Contact the merchant's support if you believe " + "this is in error." + ), + } +) + +PAYMENT_REQUIRED_INSTRUCTIONS = json.dumps( + { + "action": "contact_merchant", + "steps": [ + ( + "The merchant's AgentScore tier does not include the assess feature, so " + "agent identity cannot be evaluated. This is a merchant-side configuration " + "gap — there is no agent-side recovery." + ), + ( + "Contact the merchant (their support channel — typically listed in " + "/llms.txt or the OpenAPI servers metadata) and request they upgrade " + "their AgentScore plan." + ), + ], + "user_message": ( + "This merchant's identity gate is misconfigured (AgentScore tier doesn't " + "support assess). Contact the merchant — there's nothing to fix on the " + "agent side." + ), + } +) + +# Fallback when API didn't supply next_steps. Normal path provides them; this is +# defense-in-depth so 403s never go out without a machine-readable recovery step. +IDENTITY_VERIFICATION_REQUIRED_FALLBACK_INSTRUCTIONS = json.dumps( + { + "action": "deliver_verify_url_and_poll", + "steps": [ + "Share verify_url with the user — they complete identity verification on AgentScore.", + ( + "If session_id + poll_secret are present in the body, poll poll_url every " + "5 seconds with header `X-Poll-Secret: ` until status=verified. " + "The poll returns a one-time operator_token." + ), + "Retry the original request with header `X-Operator-Token: `.", + ], + "user_message": ( + "Identity verification is required. Visit verify_url, then poll poll_url for the operator token and retry." + ), + } +) + +TOKEN_EXPIRED_FALLBACK_INSTRUCTIONS = json.dumps( + { + "action": "deliver_verify_url_and_poll", + "steps": [ + ( + "The operator token is expired or revoked. AgentScore auto-mints a fresh " + "verification session — complete it to receive a new opc_..." + ), + ( + "Share verify_url with the user, then poll poll_url every 5 seconds with " + "header `X-Poll-Secret: ` until status=verified. The poll " + "returns a fresh one-time operator_token." + ), + "Retry the original request with header `X-Operator-Token: `.", + ], + "user_message": ( + "Operator token is expired or revoked. A new verification session has been " + "minted — visit verify_url to refresh." + ), + } +) + +# Default agent_instructions per denial code. Adapters can override by passing +# ``agent_instructions=`` on the DenialReason; otherwise the body emitter looks +# up this map so every denial carries a machine-readable next step. +_DEFAULT_AGENT_INSTRUCTIONS: dict[str, str] = { + "missing_identity": _MISSING_IDENTITY_INSTRUCTIONS, + "wallet_signer_mismatch": WALLET_SIGNER_MISMATCH_INSTRUCTIONS, + "wallet_auth_requires_wallet_signing": WALLET_AUTH_REQUIRES_WALLET_SIGNING_INSTRUCTIONS, + "wallet_not_trusted": WALLET_NOT_TRUSTED_INSTRUCTIONS, + "payment_required": PAYMENT_REQUIRED_INSTRUCTIONS, + "identity_verification_required": IDENTITY_VERIFICATION_REQUIRED_FALLBACK_INSTRUCTIONS, + "token_expired": TOKEN_EXPIRED_FALLBACK_INSTRUCTIONS, +} + def build_missing_identity_reason() -> DenialReason: """Construct a missing_identity DenialReason with the cross-merchant memory hint attached. @@ -189,8 +298,9 @@ def denial_reason_to_body(reason: DenialReason) -> dict[str, Any]: body["poll_secret"] = reason.poll_secret if reason.poll_url: body["poll_url"] = reason.poll_url - if reason.agent_instructions: - body["agent_instructions"] = reason.agent_instructions + instructions = reason.agent_instructions or _DEFAULT_AGENT_INSTRUCTIONS.get(reason.code) + if instructions: + body["agent_instructions"] = instructions # Cross-merchant pattern hint. if reason.agent_memory is not None: body["agent_memory"] = asdict(reason.agent_memory) diff --git a/agentscore_commerce/identity/aiohttp.py b/agentscore_commerce/identity/aiohttp.py index b56b144..0281a52 100644 --- a/agentscore_commerce/identity/aiohttp.py +++ b/agentscore_commerce/identity/aiohttp.py @@ -179,6 +179,22 @@ async def _agentscore_middleware( request["agentscore"] = result.raw return await handler(request) + # Fixable compliance denials (kyc_required, kyc_pending, kyc_failed, + # jurisdiction_required when not explicitly restricted) get the same UX as + # missing_identity: the gate mints a fresh verification session, the agent + # polls until status=verified, gets a fresh opc_..., and retries with + # X-Operator-Token. Unfixable reasons (sanctions, age, jurisdiction_restricted) + # keep the bare wallet_not_trusted denial — re-verification won't fix them. + if is_fixable_denial(result.reasons) and create_session_on_missing is not None: + session_reason = await try_create_session_denial_reason( + create_session_on_missing, + client.user_agent, + request, + ) + if session_reason is not None: + body, status = _on_denied(request, session_reason) + return web.json_response(body, status=status) + reason = DenialReason( code="wallet_not_trusted", decision=result.decision, diff --git a/agentscore_commerce/identity/django.py b/agentscore_commerce/identity/django.py index 9d06946..ab6976f 100644 --- a/agentscore_commerce/identity/django.py +++ b/agentscore_commerce/identity/django.py @@ -170,6 +170,21 @@ def __call__(self, request: HttpRequest) -> Any: setattr(request, "agentscore", result.raw) # noqa: B010 — dynamic attribute attach on HttpRequest return self.get_response(request) + # Fixable compliance denials (kyc_required, kyc_pending, kyc_failed, + # jurisdiction_required when not explicitly restricted) get the same UX as + # missing_identity: the gate mints a fresh verification session, the agent + # polls until status=verified, gets a fresh opc_..., and retries with + # X-Operator-Token. Unfixable reasons (sanctions, age, jurisdiction_restricted) + # keep the bare wallet_not_trusted denial — re-verification won't fix them. + if is_fixable_denial(result.reasons) and self._create_session_on_missing is not None: + session_reason = try_create_session_denial_reason_sync( + self._create_session_on_missing, + self._client.user_agent, + request, + ) + if session_reason is not None: + return self._on_denied(request, session_reason) + reason = DenialReason( code="wallet_not_trusted", decision=result.decision, diff --git a/agentscore_commerce/identity/fastapi.py b/agentscore_commerce/identity/fastapi.py index 488c676..e8ccd31 100644 --- a/agentscore_commerce/identity/fastapi.py +++ b/agentscore_commerce/identity/fastapi.py @@ -208,6 +208,22 @@ async def __call__(self, request: Request) -> None: setattr(request.state, ASSESS_STATE_KEY, result.raw) return + # Fixable compliance denials (kyc_required, kyc_pending, kyc_failed, + # jurisdiction_required when not explicitly restricted) get the same UX as + # missing_identity: the gate mints a fresh verification session, the agent + # polls until status=verified, gets a fresh opc_..., and retries with + # X-Operator-Token. No "go to verify_url and tell us when done" gap. + # Unfixable reasons (sanctions, age, jurisdiction_restricted) keep the + # bare wallet_not_trusted denial — re-verification won't fix them. + if is_fixable_denial(result.reasons) and self._create_session_on_missing is not None: + session_reason = await try_create_session_denial_reason( + self._create_session_on_missing, + self._client.user_agent, + request, + ) + if session_reason is not None: + self._deny(request, session_reason) + self._deny( request, DenialReason( diff --git a/agentscore_commerce/identity/flask.py b/agentscore_commerce/identity/flask.py index eeef925..9942e3e 100644 --- a/agentscore_commerce/identity/flask.py +++ b/agentscore_commerce/identity/flask.py @@ -144,6 +144,14 @@ def agentscore_gate( _extract_chain = extract_chain or _default_extract_chain _on_denied = on_denied or _default_on_denied + def _deny(reason: DenialReason) -> tuple[Response, int]: + try: + body, status = _on_denied(flask_request, reason) + except (TypeError, ValueError) as exc: + msg = "on_denied must return a (dict, int) tuple, e.g. ({'error': 'denied'}, 403)" + raise TypeError(msg) from exc + return jsonify(body), status + @app.before_request def _agentscore_check() -> Response | tuple[Response, int] | None: identity = _resolve_identity(flask_request) @@ -165,12 +173,7 @@ def _agentscore_check() -> Response | tuple[Response, int] | None: ) if session_reason is not None: denial_reason = session_reason - try: - body, status = _on_denied(flask_request, denial_reason) - except (TypeError, ValueError) as exc: - msg = "on_denied must return a (dict, int) tuple, e.g. ({'error': 'denied'}, 403)" - raise TypeError(msg) from exc - return jsonify(body), status + return _deny(denial_reason) chain_override = _extract_chain(flask_request) @@ -181,54 +184,44 @@ def _agentscore_check() -> Response | tuple[Response, int] | None: g.agentscore = result.raw return None - reason = DenialReason( - code="wallet_not_trusted", - decision=result.decision, - reasons=result.reasons, - verify_url=result.verify_url, + # Fixable compliance denials (kyc_required, kyc_pending, kyc_failed, + # jurisdiction_required when not explicitly restricted) get the same UX as + # missing_identity: the gate mints a fresh verification session, the agent + # polls until status=verified, gets a fresh opc_..., and retries with + # X-Operator-Token. Unfixable reasons (sanctions, age, jurisdiction_restricted) + # keep the bare wallet_not_trusted denial — re-verification won't fix them. + if is_fixable_denial(result.reasons) and create_session_on_missing is not None: + session_reason = try_create_session_denial_reason_sync( + create_session_on_missing, + client.user_agent, + flask_request, + ) + if session_reason is not None: + return _deny(session_reason) + + return _deny( + DenialReason( + code="wallet_not_trusted", + decision=result.decision, + reasons=result.reasons, + verify_url=result.verify_url, + ), ) - try: - body, status = _on_denied(flask_request, reason) - except (TypeError, ValueError) as exc: - msg = "on_denied must return a (dict, int) tuple, e.g. ({'error': 'denied'}, 403)" - raise TypeError(msg) from exc - return jsonify(body), status except PaymentRequiredError: if client.fail_open: return None - try: - body, status = _on_denied(flask_request, DenialReason(code="payment_required")) - except (TypeError, ValueError) as exc: - msg = "on_denied must return a (dict, int) tuple, e.g. ({'error': 'denied'}, 403)" - raise TypeError(msg) from exc - return jsonify(body), status + return _deny(DenialReason(code="payment_required")) except TokenDeniedError as err: - reason = build_token_denied_reason(err) - try: - body, status = _on_denied(flask_request, reason) - except (TypeError, ValueError) as exc: - msg = "on_denied must return a (dict, int) tuple, e.g. ({'error': 'denied'}, 403)" - raise TypeError(msg) from exc - return jsonify(body), status + return _deny(build_token_denied_reason(err)) except InvalidCredentialError: # Permanent — no auto-session, agent should switch tokens or restart. - try: - body, status = _on_denied(flask_request, build_invalid_credential_reason()) - except (TypeError, ValueError) as exc: - msg = "on_denied must return a (dict, int) tuple, e.g. ({'error': 'denied'}, 403)" - raise TypeError(msg) from exc - return jsonify(body), status + return _deny(build_invalid_credential_reason()) except TypeError: raise except Exception: if client.fail_open: return None - try: - body, status = _on_denied(flask_request, DenialReason(code="api_error")) - except (TypeError, ValueError) as exc: - msg = "on_denied must return a (dict, int) tuple, e.g. ({'error': 'denied'}, 403)" - raise TypeError(msg) from exc - return jsonify(body), status + return _deny(DenialReason(code="api_error")) def verify_wallet_signer_match( diff --git a/agentscore_commerce/identity/middleware.py b/agentscore_commerce/identity/middleware.py index b8402ae..6628295 100644 --- a/agentscore_commerce/identity/middleware.py +++ b/agentscore_commerce/identity/middleware.py @@ -191,6 +191,23 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: await self.app(scope, receive, send) return + # Fixable compliance denials (kyc_required, kyc_pending, kyc_failed, + # jurisdiction_required when not explicitly restricted) get the same UX as + # missing_identity: the gate mints a fresh verification session, the agent + # polls until status=verified, gets a fresh opc_..., and retries with + # X-Operator-Token. Unfixable reasons (sanctions, age, jurisdiction_restricted) + # keep the bare wallet_not_trusted denial — re-verification won't fix them. + if is_fixable_denial(result.reasons) and self._create_session_on_missing is not None: + session_reason = await try_create_session_denial_reason( + self._create_session_on_missing, + self._client.user_agent, + request, + ) + if session_reason is not None: + response = await self._on_denied(request, session_reason) + await response(scope, receive, send) + return + reason = DenialReason( code="wallet_not_trusted", decision=result.decision, diff --git a/agentscore_commerce/identity/sanic.py b/agentscore_commerce/identity/sanic.py index 7e4e818..0c163eb 100644 --- a/agentscore_commerce/identity/sanic.py +++ b/agentscore_commerce/identity/sanic.py @@ -181,6 +181,22 @@ async def _agentscore_check(request: Request) -> HTTPResponse | None: request.ctx.agentscore = result.raw return None + # Fixable compliance denials (kyc_required, kyc_pending, kyc_failed, + # jurisdiction_required when not explicitly restricted) get the same UX as + # missing_identity: the gate mints a fresh verification session, the agent + # polls until status=verified, gets a fresh opc_..., and retries with + # X-Operator-Token. Unfixable reasons (sanctions, age, jurisdiction_restricted) + # keep the bare wallet_not_trusted denial — re-verification won't fix them. + if is_fixable_denial(result.reasons) and create_session_on_missing is not None: + session_reason = await try_create_session_denial_reason( + create_session_on_missing, + client.user_agent, + request, + ) + if session_reason is not None: + body, status = _on_denied(request, session_reason) + return response.json(body, status=status) + reason = DenialReason( code="wallet_not_trusted", decision=result.decision, diff --git a/pyproject.toml b/pyproject.toml index b390068..c638fb2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "agentscore-commerce" -version = "1.0.0" +version = "1.0.1" description = "Agent commerce SDK for Python — identity middleware (FastAPI, Flask, Django, AIOHTTP, Sanic, ASGI) + payment helpers + 402 builders + discovery + Stripe multichain. The full merchant-side toolkit for AgentScore-powered agent commerce." readme = "README.md" license = "MIT" diff --git a/tests/test_aiohttp.py b/tests/test_aiohttp.py index 53a9fcd..0dc3315 100644 --- a/tests/test_aiohttp.py +++ b/tests/test_aiohttp.py @@ -239,6 +239,44 @@ async def test_falls_back_to_missing_identity_on_session_api_failure(self): data = await resp.json() assert data["error"]["code"] == "missing_identity" + @pytest.mark.asyncio + @respx.mock + async def test_fixable_wallet_denial_bootstraps_session(self): + _mock_assess("deny", reasons=["kyc_required"]) + respx.post(SESSIONS_URL).mock( + return_value=httpx.Response( + 200, + json={ + "session_id": "sess_kyc", + "verify_url": "https://agentscore.sh/verify/sess_kyc", + "poll_secret": "ps_kyc", + "next_steps": {"action": "deliver_verify_url_and_poll"}, + }, + ) + ) + app = _make_app(create_session_on_missing=CreateSessionOnMissing(api_key="ask_session")) + client = await _client(app) + async with client: + resp = await client.get("/", headers={"X-Wallet-Address": "0xabc"}) + assert resp.status == 403 + data = await resp.json() + assert data["error"]["code"] == "identity_verification_required" + assert data["session_id"] == "sess_kyc" + + @pytest.mark.asyncio + @respx.mock + async def test_unfixable_wallet_denial_returns_bare_wallet_not_trusted(self): + _mock_assess("deny", reasons=["sanctions_flagged"]) + sessions_route = respx.post(SESSIONS_URL) + app = _make_app(create_session_on_missing=CreateSessionOnMissing(api_key="ask_session")) + client = await _client(app) + async with client: + resp = await client.get("/", headers={"X-Wallet-Address": "0xabc"}) + assert resp.status == 403 + data = await resp.json() + assert data["error"]["code"] == "wallet_not_trusted" + assert sessions_route.call_count == 0 + class TestCaptureWallet: @pytest.mark.asyncio diff --git a/tests/test_django.py b/tests/test_django.py index 1bab28a..de1c258 100644 --- a/tests/test_django.py +++ b/tests/test_django.py @@ -283,6 +283,60 @@ def test_falls_back_to_missing_identity_on_session_helper_failure(self) -> None: data = json.loads(resp.content) assert data["error"]["code"] == "missing_identity" + def test_fixable_wallet_denial_bootstraps_session(self) -> None: + from agentscore_commerce.identity.sessions import CreateSessionOnMissing + from agentscore_commerce.identity.types import DenialReason + + mw = self._make_middleware( + create_session_on_missing=CreateSessionOnMissing(api_key="ask_session"), + ) + result = AssessResult(allow=False, decision="deny", reasons=["kyc_required"], raw={}) + session_reason = DenialReason( + code="identity_verification_required", + verify_url="https://agentscore.sh/verify/sess_kyc", + session_id="sess_kyc", + poll_secret="ps_kyc", + ) + request = self.factory.get("/", HTTP_X_WALLET_ADDRESS="0xabc") + with ( + patch( + "agentscore_commerce.identity.django.GateClient.check", + return_value=result, + ), + patch( + "agentscore_commerce.identity.django.try_create_session_denial_reason_sync", + return_value=session_reason, + ), + ): + resp = mw(request) + assert resp.status_code == 403 + data = json.loads(resp.content) + assert data["error"]["code"] == "identity_verification_required" + assert data["session_id"] == "sess_kyc" + + def test_unfixable_wallet_denial_returns_bare_wallet_not_trusted(self) -> None: + from agentscore_commerce.identity.sessions import CreateSessionOnMissing + + mw = self._make_middleware( + create_session_on_missing=CreateSessionOnMissing(api_key="ask_session"), + ) + result = AssessResult(allow=False, decision="deny", reasons=["sanctions_flagged"], raw={}) + request = self.factory.get("/", HTTP_X_WALLET_ADDRESS="0xabc") + with ( + patch( + "agentscore_commerce.identity.django.GateClient.check", + return_value=result, + ), + patch( + "agentscore_commerce.identity.django.try_create_session_denial_reason_sync", + ) as session_helper, + ): + resp = mw(request) + assert resp.status_code == 403 + data = json.loads(resp.content) + assert data["error"]["code"] == "wallet_not_trusted" + session_helper.assert_not_called() + class TestDjangoIdentityModel: """Django middleware identity model tests.""" diff --git a/tests/test_fastapi.py b/tests/test_fastapi.py index 02f5ba3..eea22e4 100644 --- a/tests/test_fastapi.py +++ b/tests/test_fastapi.py @@ -204,6 +204,65 @@ def test_falls_back_to_missing_identity_on_session_api_failure(self): assert resp.status_code == 403 assert resp.json()["detail"]["error"]["code"] == "missing_identity" + @respx.mock + def test_fixable_wallet_denial_bootstraps_session(self): + # When /v1/assess returns deny with a fixable reason (kyc_required), the gate + # should mint a verification session via /v1/sessions and return + # identity_verification_required (not bare wallet_not_trusted), giving the + # agent the same poll-and-retry UX as missing_identity. + _mock_assess("deny", reasons=["kyc_required"]) + respx.post(SESSIONS_URL).mock( + return_value=httpx.Response( + 200, + json={ + "session_id": "sess_kyc", + "verify_url": "https://agentscore.sh/verify/sess_kyc", + "poll_secret": "ps_kyc", + "next_steps": {"action": "deliver_verify_url_and_poll"}, + }, + ) + ) + gate = AgentScoreGate( + api_key="ask_test", + create_session_on_missing=CreateSessionOnMissing(api_key="ask_session"), + ) + client = TestClient(_make_app(gate)) + resp = client.get("/", headers={"X-Wallet-Address": "0xabc"}) + assert resp.status_code == 403 + detail = resp.json()["detail"] + assert detail["error"]["code"] == "identity_verification_required" + assert detail["session_id"] == "sess_kyc" + + @respx.mock + def test_unfixable_wallet_denial_returns_bare_wallet_not_trusted(self): + # Sanctions / age / jurisdiction_restricted are unfixable — re-verification + # won't change the outcome. Gate should emit bare wallet_not_trusted (no + # session bootstrap) so the agent surfaces contact-support copy. + _mock_assess("deny", reasons=["sanctions_flagged"]) + sessions_route = respx.post(SESSIONS_URL) + gate = AgentScoreGate( + api_key="ask_test", + create_session_on_missing=CreateSessionOnMissing(api_key="ask_session"), + ) + client = TestClient(_make_app(gate)) + resp = client.get("/", headers={"X-Wallet-Address": "0xabc"}) + assert resp.status_code == 403 + assert resp.json()["detail"]["error"]["code"] == "wallet_not_trusted" + assert sessions_route.call_count == 0 + + @respx.mock + def test_fixable_wallet_falls_back_to_bare_when_session_mint_fails(self): + _mock_assess("deny", reasons=["kyc_required"]) + respx.post(SESSIONS_URL).mock(return_value=httpx.Response(500, text="oops")) + gate = AgentScoreGate( + api_key="ask_test", + create_session_on_missing=CreateSessionOnMissing(api_key="ask_session"), + ) + client = TestClient(_make_app(gate)) + resp = client.get("/", headers={"X-Wallet-Address": "0xabc"}) + assert resp.status_code == 403 + assert resp.json()["detail"]["error"]["code"] == "wallet_not_trusted" + class TestCaptureWallet: @respx.mock diff --git a/tests/test_flask.py b/tests/test_flask.py index 4b4d735..a98d8a7 100644 --- a/tests/test_flask.py +++ b/tests/test_flask.py @@ -268,6 +268,56 @@ def test_falls_back_to_missing_identity_on_session_helper_failure(self) -> None: data = resp.get_json() assert data["error"]["code"] == "missing_identity" + def test_fixable_wallet_denial_bootstraps_session(self) -> None: + from agentscore_commerce.identity.sessions import CreateSessionOnMissing + from agentscore_commerce.identity.types import DenialReason + + app = _make_app(create_session_on_missing=CreateSessionOnMissing(api_key="ask_session")) + result = AssessResult(allow=False, decision="deny", reasons=["kyc_required"], raw={}) + session_reason = DenialReason( + code="identity_verification_required", + verify_url="https://agentscore.sh/verify/sess_kyc", + session_id="sess_kyc", + poll_secret="ps_kyc", + ) + with ( + patch( + "agentscore_commerce.identity.flask.GateClient.check", + return_value=result, + ), + patch( + "agentscore_commerce.identity.flask.try_create_session_denial_reason_sync", + return_value=session_reason, + ), + ): + client = app.test_client() + resp = client.get("/", headers={"x-wallet-address": "0xabc"}) + assert resp.status_code == 403 + data = resp.get_json() + assert data["error"]["code"] == "identity_verification_required" + assert data["session_id"] == "sess_kyc" + + def test_unfixable_wallet_denial_returns_bare_wallet_not_trusted(self) -> None: + from agentscore_commerce.identity.sessions import CreateSessionOnMissing + + app = _make_app(create_session_on_missing=CreateSessionOnMissing(api_key="ask_session")) + result = AssessResult(allow=False, decision="deny", reasons=["sanctions_flagged"], raw={}) + with ( + patch( + "agentscore_commerce.identity.flask.GateClient.check", + return_value=result, + ), + patch( + "agentscore_commerce.identity.flask.try_create_session_denial_reason_sync", + ) as session_helper, + ): + client = app.test_client() + resp = client.get("/", headers={"x-wallet-address": "0xabc"}) + assert resp.status_code == 403 + data = resp.get_json() + assert data["error"]["code"] == "wallet_not_trusted" + session_helper.assert_not_called() + class TestFlaskIdentityModel: """Flask adapter identity model tests.""" @@ -496,7 +546,11 @@ def test_passes_through_token_expired_without_next_steps(self) -> None: assert resp.status_code == 401 body = resp.get_json() assert body["error"]["code"] == "token_expired" - assert "agent_instructions" not in body + # API didn't supply next_steps → fallback agent_instructions injected by + # _response.py so agents always have a recovery action. + import json as _json + + assert _json.loads(body["agent_instructions"])["action"] == "deliver_verify_url_and_poll" class TestFlaskGenericFailure: diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 59a9b13..973ed75 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -214,6 +214,37 @@ def test_fail_open_takes_precedence(self): assert resp.status_code == 200 assert session_route.call_count == 0 + @respx.mock + def test_fixable_wallet_denial_bootstraps_session(self): + _mock_assess("deny", reasons=["kyc_required"]) + respx.post(SESSIONS_URL).mock(return_value=httpx.Response(200, json=SESSION_RESPONSE)) + + app = _make_app( + create_session_on_missing=CreateSessionOnMissing(api_key="ask_session_key"), + ) + client = TestClient(app, raise_server_exceptions=False) + resp = client.get("/", headers={"x-wallet-address": "0xabc"}) + + assert resp.status_code == 403 + data = resp.json() + assert data["error"]["code"] == "identity_verification_required" + assert data["session_id"] == "sess_abc123" + + @respx.mock + def test_unfixable_wallet_denial_returns_bare_wallet_not_trusted(self): + _mock_assess("deny", reasons=["sanctions_flagged"]) + sessions_route = respx.post(SESSIONS_URL) + + app = _make_app( + create_session_on_missing=CreateSessionOnMissing(api_key="ask_session_key"), + ) + client = TestClient(app, raise_server_exceptions=False) + resp = client.get("/", headers={"x-wallet-address": "0xabc"}) + + assert resp.status_code == 403 + assert resp.json()["error"]["code"] == "wallet_not_trusted" + assert sessions_route.call_count == 0 + CAPTURE_URL = "https://api.agentscore.sh/v1/credentials/wallets" @@ -401,8 +432,9 @@ def test_middleware_passes_through_token_expired_without_next_steps(): assert resp.status_code == 401 body = resp.json() assert body["error"]["code"] == "token_expired" - # next_steps absent → agent_instructions omitted entirely. - assert "agent_instructions" not in body + # API didn't supply next_steps → fallback agent_instructions injected by + # _response.py so agents always have a recovery action. + assert json.loads(body["agent_instructions"])["action"] == "deliver_verify_url_and_poll" @respx.mock diff --git a/tests/test_response.py b/tests/test_response.py new file mode 100644 index 0000000..5b0bcea --- /dev/null +++ b/tests/test_response.py @@ -0,0 +1,73 @@ +"""Tests for the shared denial-body marshaller. + +Covers the fallback agent_instructions injection added in PR-fix-wallet-not-trusted — +every denial code that doesn't already get instructions from the gate must come out +of ``denial_reason_to_body`` with a machine-readable next-step block. +""" + +from __future__ import annotations + +import json + +from agentscore_commerce.identity._response import denial_reason_to_body +from agentscore_commerce.identity.types import DenialReason + + +def test_injects_canonical_wallet_not_trusted_instructions() -> None: + # wallet_not_trusted reaches the agent ONLY for unfixable reasons (sanctions / + # age / jurisdiction_restricted). Fixable reasons (kyc_required, etc.) are + # rerouted to identity_verification_required by the gate adapter. + body = denial_reason_to_body( + DenialReason( + code="wallet_not_trusted", + reasons=["sanctions_flagged"], + verify_url="https://agentscore.sh/dashboard/verify?address=0xabc&chain=base", + ) + ) + instructions = json.loads(body["agent_instructions"]) + assert instructions["action"] == "contact_support" + assert isinstance(instructions["steps"], list) + assert "merchant" in instructions["user_message"].lower() or "support" in instructions["user_message"].lower() + + +def test_injects_canonical_payment_required_instructions() -> None: + body = denial_reason_to_body(DenialReason(code="payment_required")) + instructions = json.loads(body["agent_instructions"]) + assert instructions["action"] == "contact_merchant" + + +def test_injects_fallback_identity_verification_required_instructions() -> None: + body = denial_reason_to_body( + DenialReason( + code="identity_verification_required", + verify_url="https://agentscore.sh/verify?session=sess_abc", + ) + ) + instructions = json.loads(body["agent_instructions"]) + assert instructions["action"] == "deliver_verify_url_and_poll" + + +def test_injects_fallback_token_expired_instructions() -> None: + body = denial_reason_to_body( + DenialReason( + code="token_expired", + verify_url="https://agentscore.sh/verify?session=sess_abc", + ) + ) + instructions = json.loads(body["agent_instructions"]) + assert instructions["action"] == "deliver_verify_url_and_poll" + + +def test_explicit_agent_instructions_takes_precedence_over_default() -> None: + custom = json.dumps({"action": "custom_action", "steps": ["custom"]}) + body = denial_reason_to_body(DenialReason(code="wallet_not_trusted", agent_instructions=custom)) + assert body["agent_instructions"] == custom + + +def test_codes_without_a_default_keep_no_instructions() -> None: + # api_error has its own next_steps fallback (not agent_instructions), + # so denial_reason_to_body should NOT inject agent_instructions for it. + body = denial_reason_to_body(DenialReason(code="api_error")) + assert "agent_instructions" not in body + # But it still gets the default retry next_steps shape. + assert body.get("next_steps") == {"action": "retry", "retry_after_seconds": 5} diff --git a/tests/test_sanic.py b/tests/test_sanic.py index 283fb72..5d6634d 100644 --- a/tests/test_sanic.py +++ b/tests/test_sanic.py @@ -210,6 +210,55 @@ def test_falls_back_to_missing_identity_on_session_helper_failure(self): assert resp.status == 403 assert resp.json["error"]["code"] == "missing_identity" + def test_fixable_wallet_denial_bootstraps_session(self): + kyc_result = AssessResult(allow=False, decision="deny", reasons=["kyc_required"], raw={}) + session_reason = DenialReason( + code="identity_verification_required", + verify_url="https://agentscore.sh/verify/sess_kyc", + session_id="sess_kyc", + poll_secret="ps_kyc", + ) + app = _make_app( + "sanic_fixable_wallet", + create_session_on_missing=CreateSessionOnMissing(api_key="ask_session"), + ) + with ( + patch( + "agentscore_commerce.identity.sanic.GateClient.acheck_identity", + new=AsyncMock(return_value=kyc_result), + ), + patch( + "agentscore_commerce.identity.sanic.try_create_session_denial_reason", + new=AsyncMock(return_value=session_reason), + ), + ): + _, resp = app.test_client.get("/", headers={"X-Wallet-Address": "0xabc"}) + assert resp.status == 403 + assert resp.json["error"]["code"] == "identity_verification_required" + assert resp.json["session_id"] == "sess_kyc" + + def test_unfixable_wallet_denial_returns_bare_wallet_not_trusted(self): + unfixable = AssessResult(allow=False, decision="deny", reasons=["sanctions_flagged"], raw={}) + session_helper = AsyncMock() + app = _make_app( + "sanic_unfixable_wallet", + create_session_on_missing=CreateSessionOnMissing(api_key="ask_session"), + ) + with ( + patch( + "agentscore_commerce.identity.sanic.GateClient.acheck_identity", + new=AsyncMock(return_value=unfixable), + ), + patch( + "agentscore_commerce.identity.sanic.try_create_session_denial_reason", + new=session_helper, + ), + ): + _, resp = app.test_client.get("/", headers={"X-Wallet-Address": "0xabc"}) + assert resp.status == 403 + assert resp.json["error"]["code"] == "wallet_not_trusted" + session_helper.assert_not_called() + class TestCaptureWallet: def test_captures_when_operator_token_present(self): diff --git a/uv.lock b/uv.lock index 126da5b..3219508 100644 --- a/uv.lock +++ b/uv.lock @@ -10,7 +10,7 @@ resolution-markers = [ [[package]] name = "agentscore-commerce" -version = "1.0.0" +version = "1.0.1" source = { editable = "." } dependencies = [ { name = "agentscore-py" }, From b4140a794a449c329660b459ceaaaed4062a867f Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Wed, 29 Apr 2026 19:38:35 -0700 Subject: [PATCH 2/8] fix: remove jurisdiction_restricted from FIXABLE_DENIAL_REASONS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API only emits jurisdiction_restricted AFTER KYC is verified — meaning the user's KYC'd country is in the merchant's blocked list (or absent from the allowed list). Re-doing KYC won't change the country, so it's permanent. Same shape as sanctions_flagged / age_insufficient — should surface contact_support, not bootstrap a doomed verification session. Also flips empty/None reasons to return False (don't bootstrap on unknown deny — default to bare denial). Updates the canonical wallet_not_trusted instructions copy and all six adapter comments to spell out the API-side rationale. Tests updated: jurisdiction_restricted now in the unfixable bucket alongside sanctions/age, empty reasons returns False. Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore_commerce/identity/_denial.py | 19 +++++++++++----- agentscore_commerce/identity/_response.py | 11 ++++++---- agentscore_commerce/identity/aiohttp.py | 14 ++++++------ agentscore_commerce/identity/django.py | 14 ++++++------ agentscore_commerce/identity/fastapi.py | 13 +++++------ agentscore_commerce/identity/flask.py | 14 ++++++------ agentscore_commerce/identity/middleware.py | 14 ++++++------ agentscore_commerce/identity/sanic.py | 14 ++++++------ tests/test_denial.py | 25 +++++++++++++++------- 9 files changed, 85 insertions(+), 53 deletions(-) diff --git a/agentscore_commerce/identity/_denial.py b/agentscore_commerce/identity/_denial.py index c28f4ba..486c464 100644 --- a/agentscore_commerce/identity/_denial.py +++ b/agentscore_commerce/identity/_denial.py @@ -23,26 +23,35 @@ from agentscore_commerce.identity.types import DenialReason, VerifyWalletSignerResult +# Compliance denial reasons that can be resolved by re-completing KYC. The API emits these +# when KYC is missing/pending/failed; the user can re-verify and retry. +# +# `jurisdiction_restricted` is NOT in this set — the API only emits it AFTER KYC is verified, +# meaning the user's KYC'd country is in the merchant's blocked list (or absent from the +# allowed list). Re-doing KYC won't change the country, so it's permanent. Same shape as +# `sanctions_flagged` and `age_insufficient` — surface contact_support, don't waste a +# /v1/sessions mint. FIXABLE_DENIAL_REASONS: frozenset[str] = frozenset( { "kyc_required", "kyc_pending", "kyc_failed", - "jurisdiction_restricted", } ) def is_fixable_denial(reasons: Iterable[str] | None) -> bool: - """Return True when every reason is fixable (or reasons is empty/None). + """Return True when every reason is fixable via KYC re-verification. - Sanctions and age failures are permanent — any of those in the list returns False. + False when any reason is permanent (sanctions, age, jurisdiction_restricted) OR when + reasons is empty/None — without a known reason we can't promise a fix, so default to + the bare denial path. """ if not reasons: - return True + return False reasons_list = list(reasons) if not reasons_list: - return True + return False return all(r in FIXABLE_DENIAL_REASONS for r in reasons_list) diff --git a/agentscore_commerce/identity/_response.py b/agentscore_commerce/identity/_response.py index c5a8631..f3ce699 100644 --- a/agentscore_commerce/identity/_response.py +++ b/agentscore_commerce/identity/_response.py @@ -142,10 +142,13 @@ ), ( "Fixable compliance reasons (`kyc_required`, `kyc_pending`, " - "`kyc_failed`, `jurisdiction_required` without explicit restriction) " - "do NOT land on this code — the gate auto-mints a verification session " - "for those and returns `identity_verification_required` with poll " - "endpoints, same shape as `missing_identity`." + "`kyc_failed`) do NOT land on this code — the gate auto-mints a " + "verification session for those and returns " + "`identity_verification_required` with poll endpoints, same shape as " + "`missing_identity`. `jurisdiction_restricted` IS in the unfixable " + "bucket because the API only emits it after KYC is verified (the " + "user's KYC'd country is in the blocked list — re-doing KYC won't " + "change the country)." ), ], "user_message": ( diff --git a/agentscore_commerce/identity/aiohttp.py b/agentscore_commerce/identity/aiohttp.py index 0281a52..114db8c 100644 --- a/agentscore_commerce/identity/aiohttp.py +++ b/agentscore_commerce/identity/aiohttp.py @@ -179,12 +179,14 @@ async def _agentscore_middleware( request["agentscore"] = result.raw return await handler(request) - # Fixable compliance denials (kyc_required, kyc_pending, kyc_failed, - # jurisdiction_required when not explicitly restricted) get the same UX as - # missing_identity: the gate mints a fresh verification session, the agent - # polls until status=verified, gets a fresh opc_..., and retries with - # X-Operator-Token. Unfixable reasons (sanctions, age, jurisdiction_restricted) - # keep the bare wallet_not_trusted denial — re-verification won't fix them. + # Fixable compliance denials (kyc_required, kyc_pending, kyc_failed) get the + # same UX as missing_identity: the gate mints a fresh verification session, + # the agent polls until status=verified, gets a fresh opc_..., and retries + # with X-Operator-Token. Unfixable reasons (sanctions_flagged, age_insufficient, + # jurisdiction_restricted) keep the bare wallet_not_trusted denial. + # `jurisdiction_restricted` is unfixable: the API only emits it after KYC is + # verified (the user's KYC'd country is in the blocked list — re-doing KYC + # won't change the country). if is_fixable_denial(result.reasons) and create_session_on_missing is not None: session_reason = await try_create_session_denial_reason( create_session_on_missing, diff --git a/agentscore_commerce/identity/django.py b/agentscore_commerce/identity/django.py index ab6976f..1d39024 100644 --- a/agentscore_commerce/identity/django.py +++ b/agentscore_commerce/identity/django.py @@ -170,12 +170,14 @@ def __call__(self, request: HttpRequest) -> Any: setattr(request, "agentscore", result.raw) # noqa: B010 — dynamic attribute attach on HttpRequest return self.get_response(request) - # Fixable compliance denials (kyc_required, kyc_pending, kyc_failed, - # jurisdiction_required when not explicitly restricted) get the same UX as - # missing_identity: the gate mints a fresh verification session, the agent - # polls until status=verified, gets a fresh opc_..., and retries with - # X-Operator-Token. Unfixable reasons (sanctions, age, jurisdiction_restricted) - # keep the bare wallet_not_trusted denial — re-verification won't fix them. + # Fixable compliance denials (kyc_required, kyc_pending, kyc_failed) get the + # same UX as missing_identity: the gate mints a fresh verification session, + # the agent polls until status=verified, gets a fresh opc_..., and retries + # with X-Operator-Token. Unfixable reasons (sanctions_flagged, age_insufficient, + # jurisdiction_restricted) keep the bare wallet_not_trusted denial. + # `jurisdiction_restricted` is unfixable: the API only emits it after KYC is + # verified (the user's KYC'd country is in the blocked list — re-doing KYC + # won't change the country). if is_fixable_denial(result.reasons) and self._create_session_on_missing is not None: session_reason = try_create_session_denial_reason_sync( self._create_session_on_missing, diff --git a/agentscore_commerce/identity/fastapi.py b/agentscore_commerce/identity/fastapi.py index e8ccd31..ce2851f 100644 --- a/agentscore_commerce/identity/fastapi.py +++ b/agentscore_commerce/identity/fastapi.py @@ -208,13 +208,14 @@ async def __call__(self, request: Request) -> None: setattr(request.state, ASSESS_STATE_KEY, result.raw) return - # Fixable compliance denials (kyc_required, kyc_pending, kyc_failed, - # jurisdiction_required when not explicitly restricted) get the same UX as - # missing_identity: the gate mints a fresh verification session, the agent - # polls until status=verified, gets a fresh opc_..., and retries with + # Fixable compliance denials (kyc_required, kyc_pending, kyc_failed) get the + # same UX as missing_identity: the gate mints a fresh verification session, the + # agent polls until status=verified, gets a fresh opc_..., and retries with # X-Operator-Token. No "go to verify_url and tell us when done" gap. - # Unfixable reasons (sanctions, age, jurisdiction_restricted) keep the - # bare wallet_not_trusted denial — re-verification won't fix them. + # Unfixable reasons (sanctions_flagged, age_insufficient, jurisdiction_restricted) + # keep the bare wallet_not_trusted denial — re-verification won't fix them. + # `jurisdiction_restricted` is unfixable because the API only emits it AFTER KYC + # is verified (the user's KYC'd country is in the blocked list). if is_fixable_denial(result.reasons) and self._create_session_on_missing is not None: session_reason = await try_create_session_denial_reason( self._create_session_on_missing, diff --git a/agentscore_commerce/identity/flask.py b/agentscore_commerce/identity/flask.py index 9942e3e..9b4d2dd 100644 --- a/agentscore_commerce/identity/flask.py +++ b/agentscore_commerce/identity/flask.py @@ -184,12 +184,14 @@ def _agentscore_check() -> Response | tuple[Response, int] | None: g.agentscore = result.raw return None - # Fixable compliance denials (kyc_required, kyc_pending, kyc_failed, - # jurisdiction_required when not explicitly restricted) get the same UX as - # missing_identity: the gate mints a fresh verification session, the agent - # polls until status=verified, gets a fresh opc_..., and retries with - # X-Operator-Token. Unfixable reasons (sanctions, age, jurisdiction_restricted) - # keep the bare wallet_not_trusted denial — re-verification won't fix them. + # Fixable compliance denials (kyc_required, kyc_pending, kyc_failed) get the + # same UX as missing_identity: the gate mints a fresh verification session, + # the agent polls until status=verified, gets a fresh opc_..., and retries + # with X-Operator-Token. Unfixable reasons (sanctions_flagged, age_insufficient, + # jurisdiction_restricted) keep the bare wallet_not_trusted denial. + # `jurisdiction_restricted` is unfixable: the API only emits it after KYC is + # verified (the user's KYC'd country is in the blocked list — re-doing KYC + # won't change the country). if is_fixable_denial(result.reasons) and create_session_on_missing is not None: session_reason = try_create_session_denial_reason_sync( create_session_on_missing, diff --git a/agentscore_commerce/identity/middleware.py b/agentscore_commerce/identity/middleware.py index 6628295..439022c 100644 --- a/agentscore_commerce/identity/middleware.py +++ b/agentscore_commerce/identity/middleware.py @@ -191,12 +191,14 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: await self.app(scope, receive, send) return - # Fixable compliance denials (kyc_required, kyc_pending, kyc_failed, - # jurisdiction_required when not explicitly restricted) get the same UX as - # missing_identity: the gate mints a fresh verification session, the agent - # polls until status=verified, gets a fresh opc_..., and retries with - # X-Operator-Token. Unfixable reasons (sanctions, age, jurisdiction_restricted) - # keep the bare wallet_not_trusted denial — re-verification won't fix them. + # Fixable compliance denials (kyc_required, kyc_pending, kyc_failed) get the + # same UX as missing_identity: the gate mints a fresh verification session, + # the agent polls until status=verified, gets a fresh opc_..., and retries + # with X-Operator-Token. Unfixable reasons (sanctions_flagged, age_insufficient, + # jurisdiction_restricted) keep the bare wallet_not_trusted denial. + # `jurisdiction_restricted` is unfixable: the API only emits it after KYC is + # verified (the user's KYC'd country is in the blocked list — re-doing KYC + # won't change the country). if is_fixable_denial(result.reasons) and self._create_session_on_missing is not None: session_reason = await try_create_session_denial_reason( self._create_session_on_missing, diff --git a/agentscore_commerce/identity/sanic.py b/agentscore_commerce/identity/sanic.py index 0c163eb..7126142 100644 --- a/agentscore_commerce/identity/sanic.py +++ b/agentscore_commerce/identity/sanic.py @@ -181,12 +181,14 @@ async def _agentscore_check(request: Request) -> HTTPResponse | None: request.ctx.agentscore = result.raw return None - # Fixable compliance denials (kyc_required, kyc_pending, kyc_failed, - # jurisdiction_required when not explicitly restricted) get the same UX as - # missing_identity: the gate mints a fresh verification session, the agent - # polls until status=verified, gets a fresh opc_..., and retries with - # X-Operator-Token. Unfixable reasons (sanctions, age, jurisdiction_restricted) - # keep the bare wallet_not_trusted denial — re-verification won't fix them. + # Fixable compliance denials (kyc_required, kyc_pending, kyc_failed) get the + # same UX as missing_identity: the gate mints a fresh verification session, + # the agent polls until status=verified, gets a fresh opc_..., and retries + # with X-Operator-Token. Unfixable reasons (sanctions_flagged, age_insufficient, + # jurisdiction_restricted) keep the bare wallet_not_trusted denial. + # `jurisdiction_restricted` is unfixable: the API only emits it after KYC is + # verified (the user's KYC'd country is in the blocked list — re-doing KYC + # won't change the country). if is_fixable_denial(result.reasons) and create_session_on_missing is not None: session_reason = await try_create_session_denial_reason( create_session_on_missing, diff --git a/tests/test_denial.py b/tests/test_denial.py index 06c1120..27aa334 100644 --- a/tests/test_denial.py +++ b/tests/test_denial.py @@ -40,20 +40,29 @@ def test_returns_403_for_everything_else(self, code): class TestIsFixableDenial: def test_known_fixable_reasons_in_set(self): - for r in ("kyc_required", "kyc_pending", "kyc_failed", "jurisdiction_restricted"): + for r in ("kyc_required", "kyc_pending", "kyc_failed"): assert r in FIXABLE_DENIAL_REASONS - def test_empty_or_none_treated_as_fixable(self): - assert is_fixable_denial(None) - assert is_fixable_denial([]) + def test_jurisdiction_restricted_is_unfixable(self): + # The API only emits jurisdiction_restricted AFTER KYC is verified, meaning the + # user's KYC'd country is in the merchant's blocked list. Re-doing KYC won't + # change the country — same shape as sanctions_flagged / age_insufficient. + assert "jurisdiction_restricted" not in FIXABLE_DENIAL_REASONS + + def test_empty_or_none_returns_false(self): + # Without a known reason we can't promise a fix — default to bare denial. + assert not is_fixable_denial(None) + assert not is_fixable_denial([]) def test_all_fixable_returns_true(self): - assert is_fixable_denial(["kyc_required", "jurisdiction_restricted"]) + assert is_fixable_denial(["kyc_required", "kyc_pending"]) def test_any_permanent_returns_false(self): - assert not is_fixable_denial(["sanctions_not_clear"]) - assert not is_fixable_denial(["age_not_verified"]) - assert not is_fixable_denial(["kyc_required", "sanctions_not_clear"]) + assert not is_fixable_denial(["sanctions_flagged"]) + assert not is_fixable_denial(["age_insufficient"]) + assert not is_fixable_denial(["jurisdiction_restricted"]) + assert not is_fixable_denial(["kyc_required", "sanctions_flagged"]) + assert not is_fixable_denial(["kyc_required", "jurisdiction_restricted"]) class TestBuildSignerMismatchBody: From 096dff7dac6d0f778ac4ab3eddb7542e240d8d4c Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Wed, 29 Apr 2026 19:42:51 -0700 Subject: [PATCH 3/8] docs: clarify compliance_merchant.py example for new bootstrap-fixable architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate now re-routes fixable reasons (kyc_required/pending/failed) upstream, so by the time wallet_not_trusted reaches the merchant's on_denied, reasons should be unfixable. The is_fixable_denial branch in the example becomes a defensive fallback (only fires if the gate's /v1/sessions mint blipped). Also clarify jurisdiction_restricted is in the unfixable bucket alongside sanctions/age — the API only emits it after KYC is verified. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/compliance_merchant.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/examples/compliance_merchant.py b/examples/compliance_merchant.py index f536542..0b12034 100644 --- a/examples/compliance_merchant.py +++ b/examples/compliance_merchant.py @@ -8,7 +8,10 @@ - AgentScoreGate with full compliance policy (KYC + sanctions + age + jurisdiction) - Custom on_denied composing commerce helpers: * verification_agent_instructions for the canonical poll-and-retry instructions - * is_fixable_denial to branch fixable (KYC re-do) vs unfixable (sanctions/age) + * is_fixable_denial defensive fallback for fixable (KYC re-do) vs unfixable + (sanctions / age / jurisdiction_restricted) compliance fails. Gate normally + re-routes fixable reasons to identity_verification_required upstream — this + branch only fires if the /v1/sessions mint blipped. * build_contact_support_next_steps for the unfixable branch * denial_reason_to_body + denial_reason_status for the standard fall-through (token_expired, invalid_credential, api_error get the right status + body for free) @@ -76,11 +79,17 @@ def _on_denied(_request: Request, reason: DenialReason) -> tuple[dict[str, Any], body["agent_instructions"] = VERIFICATION_INSTRUCTIONS return body, 403 - # wallet_not_trusted = compliance fail. Branch on fixable vs not — fixable (KYC pending/failed/ - # required, jurisdiction) gets a fresh session; unfixable (sanctions, age) gets contact-support. + # wallet_not_trusted = UNFIXABLE compliance fail (sanctions / age / jurisdiction_restricted). + # The gate auto-routes fixable reasons (kyc_required / kyc_pending / kyc_failed) to + # identity_verification_required upstream — by the time on_denied sees wallet_not_trusted, + # the reasons should be unfixable. The is_fixable_denial branch below is a defensive + # fallback in case the gate's /v1/sessions mint blipped and fell back to bare denial. if reason.code == "wallet_not_trusted": reasons = reason.reasons or [] if is_fixable_denial(reasons): + # Defensive: gate normally bootstraps these into identity_verification_required. + # If we hit this branch, the gate's /v1/sessions mint failed — surface verify_url + # so the agent can recover via the manual session flow. return { "error": {"code": "compliance_recoverable", "message": "Re-verify identity and retry."}, "reasons": reasons, From baedcc7fe5213aea1046031a3a2a9b776302ddcc Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Wed, 29 Apr 2026 19:49:13 -0700 Subject: [PATCH 4/8] chore: replace fake reason codes in tests with real API codes Tests used invented reason strings like \`not_kyc\`, \`score_too_low\`, \`sanctions_check_pending\` that don't exist in the API surface (the real codes are \`kyc_required\`, \`kyc_pending\`, \`kyc_failed\`, \`sanctions_flagged\`, \`age_insufficient\`, \`jurisdiction_restricted\`). Tests pass either way (the gate passes reasons through verbatim), but the fake strings propagate misinformation. Replace with real codes for documentation accuracy. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_aiohttp.py | 4 ++-- tests/test_client.py | 4 ++-- tests/test_django.py | 6 +++--- tests/test_fastapi.py | 4 ++-- tests/test_flask.py | 6 +++--- tests/test_sanic.py | 4 ++-- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/test_aiohttp.py b/tests/test_aiohttp.py index 0dc3315..c7b045f 100644 --- a/tests/test_aiohttp.py +++ b/tests/test_aiohttp.py @@ -78,14 +78,14 @@ async def handler(request: web.Request) -> web.Response: @pytest.mark.asyncio @respx.mock async def test_denies_untrusted_wallet(self): - _mock_assess("deny", reasons=["not_kyc"]) + _mock_assess("deny", reasons=["kyc_required"]) client = await _client(_make_app()) async with client: resp = await client.get("/", headers={"X-Wallet-Address": "0xabc"}) assert resp.status == 403 data = await resp.json() assert data["error"]["code"] == "wallet_not_trusted" - assert data["reasons"] == ["not_kyc"] + assert data["reasons"] == ["kyc_required"] @pytest.mark.asyncio async def test_missing_identity_returns_403(self): diff --git a/tests/test_client.py b/tests/test_client.py index 4fa349d..b9d918f 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -394,7 +394,7 @@ def test_full_compliance_deny_flow(self): """Integration test: full middleware flow with compliance deny.""" compliance_response = { "decision": "deny", - "decision_reasons": ["kyc_required", "sanctions_check_pending"], + "decision_reasons": ["kyc_required", "sanctions_flagged"], "score": {"value": 72, "grade": "C", "status": "scored"}, "operator_verification": { "level": "none", @@ -424,7 +424,7 @@ def test_full_compliance_deny_flow(self): assert result.allow is False assert result.decision == "deny" assert "kyc_required" in result.reasons - assert "sanctions_check_pending" in result.reasons + assert "sanctions_flagged" in result.reasons assert result.verify_url == "https://agentscore.sh/verify/xyz789" assert result.operator_verification is not None assert result.operator_verification.level == "none" diff --git a/tests/test_django.py b/tests/test_django.py index de1c258..82b473a 100644 --- a/tests/test_django.py +++ b/tests/test_django.py @@ -61,7 +61,7 @@ def test_allows_trusted_wallet(self) -> None: def test_blocks_untrusted_wallet(self) -> None: mw = self._make_middleware() - result = AssessResult(allow=False, decision="deny", reasons=["score_too_low"], raw={}) + result = AssessResult(allow=False, decision="deny", reasons=["kyc_required"], raw={}) request = self.factory.get("/", HTTP_X_WALLET_ADDRESS="0xabc") with patch("agentscore_commerce.identity.django.GateClient.check", return_value=result): resp = mw(request) @@ -178,7 +178,7 @@ def test_deny_includes_reasons_from_compliance(self) -> None: result = AssessResult( allow=False, decision="deny", - reasons=["kyc_required", "sanctions_check_pending"], + reasons=["kyc_required", "sanctions_flagged"], raw={ "verify_url": "https://agentscore.sh/verify/abc123", "operator_verification": {"level": "none"}, @@ -191,7 +191,7 @@ def test_deny_includes_reasons_from_compliance(self) -> None: data = json.loads(resp.content) assert data["error"]["code"] == "wallet_not_trusted" assert "kyc_required" in data["reasons"] - assert "sanctions_check_pending" in data["reasons"] + assert "sanctions_flagged" in data["reasons"] def test_allow_with_operator_verification_attaches_to_request(self) -> None: mw = self._make_middleware() diff --git a/tests/test_fastapi.py b/tests/test_fastapi.py index eea22e4..6b60e67 100644 --- a/tests/test_fastapi.py +++ b/tests/test_fastapi.py @@ -57,7 +57,7 @@ def test_allows_trusted_wallet(self): @respx.mock def test_denies_untrusted_wallet(self): - _mock_assess("deny", reasons=["not_kyc"]) + _mock_assess("deny", reasons=["kyc_required"]) gate = AgentScoreGate(api_key="ask_test") client = TestClient(_make_app(gate)) resp = client.get("/", headers={"X-Wallet-Address": "0xabc"}) @@ -65,7 +65,7 @@ def test_denies_untrusted_wallet(self): body = resp.json() # FastAPI wraps HTTPException detail in {"detail": {...}}. assert body["detail"]["error"]["code"] == "wallet_not_trusted" - assert body["detail"]["reasons"] == ["not_kyc"] + assert body["detail"]["reasons"] == ["kyc_required"] def test_missing_identity_returns_403(self): gate = AgentScoreGate(api_key="ask_test") diff --git a/tests/test_flask.py b/tests/test_flask.py index a98d8a7..d003776 100644 --- a/tests/test_flask.py +++ b/tests/test_flask.py @@ -64,7 +64,7 @@ def test_get_assess_data_returns_none_outside_request(self) -> None: def test_blocks_untrusted_wallet(self) -> None: app = _make_app() - result = AssessResult(allow=False, decision="deny", reasons=["score_too_low"], raw={}) + result = AssessResult(allow=False, decision="deny", reasons=["kyc_required"], raw={}) with patch("agentscore_commerce.identity.flask.GateClient.check", return_value=result): client = app.test_client() resp = client.get("/", headers={"x-wallet-address": "0xabc"}) @@ -176,7 +176,7 @@ def test_deny_includes_compliance_reasons(self) -> None: result = AssessResult( allow=False, decision="deny", - reasons=["kyc_required", "sanctions_check_pending"], + reasons=["kyc_required", "sanctions_flagged"], raw={ "verify_url": "https://agentscore.sh/verify/abc123", "operator_verification": {"level": "none"}, @@ -189,7 +189,7 @@ def test_deny_includes_compliance_reasons(self) -> None: data = resp.get_json() assert data["error"]["code"] == "wallet_not_trusted" assert "kyc_required" in data["reasons"] - assert "sanctions_check_pending" in data["reasons"] + assert "sanctions_flagged" in data["reasons"] def test_allow_with_operator_verification_attaches_to_g(self) -> None: app = _make_app() diff --git a/tests/test_sanic.py b/tests/test_sanic.py index 5d6634d..f661789 100644 --- a/tests/test_sanic.py +++ b/tests/test_sanic.py @@ -21,7 +21,7 @@ def _allow_result() -> AssessResult: def _deny_result() -> AssessResult: - return AssessResult(allow=False, decision="deny", reasons=["not_kyc"]) + return AssessResult(allow=False, decision="deny", reasons=["kyc_required"]) def _make_app(name: str, **gate_kwargs) -> Sanic: @@ -78,7 +78,7 @@ def test_denies_untrusted_wallet(self): _, resp = app.test_client.get("/", headers={"X-Wallet-Address": "0xabc"}) assert resp.status == 403 assert resp.json["error"]["code"] == "wallet_not_trusted" - assert resp.json["reasons"] == ["not_kyc"] + assert resp.json["reasons"] == ["kyc_required"] def test_missing_identity_returns_403(self): app = _make_app("sanic_missing") From 9bdf304d4550c6a4ca7597f63a8d69929b4e3038 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Wed, 29 Apr 2026 20:01:42 -0700 Subject: [PATCH 5/8] fix: read __version__ from importlib.metadata (single source) Hardcoding `__version__ = "1.0.0"` in `__init__.py` while pyproject.toml is at "1.0.1" creates a two-spot version drift. Read from `importlib.metadata` so pyproject.toml is the single source of truth (matches python-sdk's pattern). Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore_commerce/__init__.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/agentscore_commerce/__init__.py b/agentscore_commerce/__init__.py index e4c314f..8cde716 100644 --- a/agentscore_commerce/__init__.py +++ b/agentscore_commerce/__init__.py @@ -9,4 +9,13 @@ agentscore_commerce.api - AgentScore SDK re-export """ -__version__ = "1.0.0" +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as _pkg_version + +try: + __version__ = _pkg_version("agentscore-commerce") +except PackageNotFoundError: + # Editable install or pre-build state — fall back to a sentinel so consumers + # don't crash on a missing dist-info dir. Real version always comes from + # pyproject.toml at install time. + __version__ = "0.0.0+local" From 84b789f7a1ce5cfb7d9f615c56f79b34d9bc827e Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Wed, 29 Apr 2026 20:44:24 -0700 Subject: [PATCH 6/8] =?UTF-8?q?docs:=20examples=20README=20=E2=80=94=20"si?= =?UTF-8?q?x"=20=E2=86=92=20"seven"=20(per=5Fproduct=5Fpolicy=5Fmerchant?= =?UTF-8?q?=20added=20previously)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/README.md b/examples/README.md index a053b70..eb509a1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -23,7 +23,7 @@ Runnable, copy-pasteable example integrations covering the most common merchant ## Patterns -All six examples follow the same rough shape: +All seven examples follow the same rough shape: 1. **Boot:** instantiate FastAPI, identity gate (if any), Stripe / facilitator clients (if any) via commerce factories 2. **Discovery routes:** `/openapi.json` + `/.well-known/mpp.json` + `/llms.txt` (omitted in these focused examples; see node-commerce for the discovery wiring) From 8ef6507bcadf166b059226e10bbc21e4c81741de Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Wed, 29 Apr 2026 21:25:11 -0700 Subject: [PATCH 7/8] fix(test): tighten URL substring assertion to silence CodeQL false positive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL flagged \`assert "https://my.merchant" in section\` as \`py/incomplete-url-substring-sanitization\` (high severity). The pattern matters when checking whether a user-supplied URL falls inside an allowlist substring; here it's a test assertion verifying the rendered llms.txt section contains the test fixture's app_url. Same effect with a more specific substring (\`agentscore-pay pay POST https://my.merchant\`) — CodeQL no longer matches the dangerous pattern. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_discovery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 3b4a476..101fa48 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -124,7 +124,7 @@ def test_emits_multi_step_setup_per_rail(self): assert "### How to pay with x402" in section assert "npm install -g @agent-score/pay" in section assert "agentscore-pay wallet create" in section - assert "https://my.merchant" in section + assert "agentscore-pay pay POST https://my.merchant" in section def test_omits_sections_for_unconfigured_rails(self): section = llms_txt_payment_section( From 16b2f766522bff1d877652852935bd97c6e40c9d Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Wed, 29 Apr 2026 21:27:28 -0700 Subject: [PATCH 8/8] =?UTF-8?q?chore(deps):=20bump=20agentscore-py=202.0.0?= =?UTF-8?q?=20=E2=86=92=202.0.1=20in=20lockfile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks up the just-published 2.0.1 (invalid_credential DenialCode + assess refresh wire-format parity with node-sdk). 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 3219508..976f27f 100644 --- a/uv.lock +++ b/uv.lock @@ -110,14 +110,14 @@ dev = [ [[package]] name = "agentscore-py" -version = "2.0.0" +version = "2.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/31/2d/2fc4330e447ccbd13ee668014527b7756e6143c3933dc21ad62a52ee5458/agentscore_py-2.0.0.tar.gz", hash = "sha256:aaffe64da63c35e4c6ce898949d5618875a99991c38698ea215379ab41ca06e8", size = 50349, upload-time = "2026-04-29T12:02:02.3Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/32/861a7b4d19f677f103ac2b54462e121f76906dbc5ef0119d283ae9276af1/agentscore_py-2.0.1.tar.gz", hash = "sha256:518fac4749aeaaca3a895087d2cb3f6d4e264db15fb194c2a59d4a35bb986a45", size = 50755, upload-time = "2026-04-30T04:19:44.312Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/68/86afad9fa422faf5cf7526e872af440b88b4925a7d87b4bc4e4e0944edba/agentscore_py-2.0.0-py3-none-any.whl", hash = "sha256:a5d2d281b76a7a90dde7ea5efc15129fbad179cefabaf70f088320e19af34c04", size = 14196, upload-time = "2026-04-29T12:02:00.986Z" }, + { url = "https://files.pythonhosted.org/packages/32/4b/bb898c79e62e4b4c4d9e8b4f621192ceeecdcd03fff2d12a50f00e74a800/agentscore_py-2.0.1-py3-none-any.whl", hash = "sha256:141eee41337e2156b9324527981f64a6edbdc375c4c218dd8c8d17cb287e1281", size = 14492, upload-time = "2026-04-30T04:19:43Z" }, ] [[package]]