agentscore-gate 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentscore_gate/__init__.py +22 -0
- agentscore_gate/cache.py +51 -0
- agentscore_gate/client.py +122 -0
- agentscore_gate/django.py +98 -0
- agentscore_gate/flask.py +126 -0
- agentscore_gate/middleware.py +126 -0
- agentscore_gate/py.typed +0 -0
- agentscore_gate/types.py +27 -0
- agentscore_gate-1.0.0.dist-info/METADATA +107 -0
- agentscore_gate-1.0.0.dist-info/RECORD +12 -0
- agentscore_gate-1.0.0.dist-info/WHEEL +4 -0
- agentscore_gate-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Trust-gating middleware for Python web frameworks using AgentScore."""
|
|
2
|
+
|
|
3
|
+
from agentscore_gate.client import GateClient
|
|
4
|
+
from agentscore_gate.types import AssessResult, DenialReason, Grade
|
|
5
|
+
|
|
6
|
+
# ASGI middleware is the default import.
|
|
7
|
+
# Flask and Django adapters are imported from their submodules:
|
|
8
|
+
# from agentscore_gate.flask import agentscore_gate
|
|
9
|
+
# from agentscore_gate.django import AgentScoreMiddleware
|
|
10
|
+
try:
|
|
11
|
+
from agentscore_gate.middleware import AgentScoreGate
|
|
12
|
+
except ImportError:
|
|
13
|
+
# starlette not installed
|
|
14
|
+
AgentScoreGate = None # type: ignore[assignment,misc]
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"AgentScoreGate",
|
|
18
|
+
"AssessResult",
|
|
19
|
+
"DenialReason",
|
|
20
|
+
"GateClient",
|
|
21
|
+
"Grade",
|
|
22
|
+
]
|
agentscore_gate/cache.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from typing import Generic, TypeVar
|
|
6
|
+
|
|
7
|
+
T = TypeVar("T")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TTLCache(Generic[T]):
|
|
11
|
+
"""Simple in-memory cache with per-entry TTL expiry."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, default_ttl_seconds: float, max_size: int = 10000) -> None:
|
|
14
|
+
self._store: dict[str, tuple[T, float]] = {}
|
|
15
|
+
self._default_ttl = default_ttl_seconds
|
|
16
|
+
self._max_size = max_size
|
|
17
|
+
self._lock = threading.Lock()
|
|
18
|
+
|
|
19
|
+
def get(self, key: str) -> T | None:
|
|
20
|
+
"""Return the cached value, or ``None`` if missing or expired."""
|
|
21
|
+
with self._lock:
|
|
22
|
+
entry = self._store.get(key)
|
|
23
|
+
if entry is None:
|
|
24
|
+
return None
|
|
25
|
+
value, expires_at = entry
|
|
26
|
+
if time.monotonic() > expires_at:
|
|
27
|
+
del self._store[key]
|
|
28
|
+
return None
|
|
29
|
+
return value
|
|
30
|
+
|
|
31
|
+
def set(self, key: str, value: T, ttl: float | None = None) -> None:
|
|
32
|
+
"""Store *value* under *key* with an optional custom TTL (seconds)."""
|
|
33
|
+
with self._lock:
|
|
34
|
+
if len(self._store) >= self._max_size and key not in self._store:
|
|
35
|
+
self._sweep_expired()
|
|
36
|
+
if len(self._store) >= self._max_size:
|
|
37
|
+
self._evict_oldest()
|
|
38
|
+
self._store[key] = (value, time.monotonic() + (ttl if ttl is not None else self._default_ttl))
|
|
39
|
+
|
|
40
|
+
def _sweep_expired(self) -> None:
|
|
41
|
+
"""Remove all expired entries. Must be called with ``_lock`` held."""
|
|
42
|
+
now = time.monotonic()
|
|
43
|
+
expired = [k for k, (_, exp) in self._store.items() if now > exp]
|
|
44
|
+
for k in expired:
|
|
45
|
+
del self._store[k]
|
|
46
|
+
|
|
47
|
+
def _evict_oldest(self) -> None:
|
|
48
|
+
"""Evict entries with the earliest expiry until under max_size. Must be called with ``_lock`` held."""
|
|
49
|
+
entries = sorted(self._store.items(), key=lambda item: item[1][1])
|
|
50
|
+
while len(self._store) >= self._max_size and entries:
|
|
51
|
+
del self._store[entries.pop(0)[0]]
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Shared AgentScore assess client with TTL caching."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from agentscore_gate.cache import TTLCache
|
|
11
|
+
from agentscore_gate.types import AssessResult, Grade
|
|
12
|
+
|
|
13
|
+
DEFAULT_BASE_URL = "https://api.agentscore.sh"
|
|
14
|
+
DEFAULT_CACHE_SECONDS = 300
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GateClient:
|
|
18
|
+
"""Shared client for calling the AgentScore assess API.
|
|
19
|
+
|
|
20
|
+
Manages caching and policy construction. Used by all framework adapters.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
*,
|
|
26
|
+
api_key: str,
|
|
27
|
+
min_grade: Grade | None = None,
|
|
28
|
+
min_score: int | None = None,
|
|
29
|
+
require_verified_activity: bool | None = None,
|
|
30
|
+
fail_open: bool = False,
|
|
31
|
+
cache_seconds: int = DEFAULT_CACHE_SECONDS,
|
|
32
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
33
|
+
) -> None:
|
|
34
|
+
if not api_key:
|
|
35
|
+
msg = "AgentScore API key is required. Get one at https://agentscore.sh/sign-up"
|
|
36
|
+
raise ValueError(msg)
|
|
37
|
+
|
|
38
|
+
self.fail_open = fail_open
|
|
39
|
+
self._api_key = api_key
|
|
40
|
+
self._base_url = base_url
|
|
41
|
+
self._cache: TTLCache[AssessResult] = TTLCache(cache_seconds)
|
|
42
|
+
|
|
43
|
+
self._policy: dict[str, Any] = {}
|
|
44
|
+
if min_grade is not None:
|
|
45
|
+
self._policy["min_grade"] = min_grade
|
|
46
|
+
if min_score is not None:
|
|
47
|
+
self._policy["min_score"] = min_score
|
|
48
|
+
if require_verified_activity is not None:
|
|
49
|
+
self._policy["require_verified_payment_activity"] = require_verified_activity
|
|
50
|
+
|
|
51
|
+
self._async_client = httpx.AsyncClient(timeout=10.0)
|
|
52
|
+
self._sync_client = httpx.Client(timeout=10.0)
|
|
53
|
+
|
|
54
|
+
def _cache_key(self, address: str, chain: str) -> str:
|
|
55
|
+
return f"{chain}:{address.lower()}"
|
|
56
|
+
|
|
57
|
+
def _build_body(self, address: str, chain: str) -> dict[str, Any]:
|
|
58
|
+
body: dict[str, Any] = {"address": address, "chain": chain}
|
|
59
|
+
if self._policy:
|
|
60
|
+
body["policy"] = self._policy
|
|
61
|
+
return body
|
|
62
|
+
|
|
63
|
+
def _headers(self) -> dict[str, str]:
|
|
64
|
+
return {
|
|
65
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
66
|
+
"Content-Type": "application/json",
|
|
67
|
+
"Accept": "application/json",
|
|
68
|
+
"User-Agent": "agentscore-gate/1.0.0",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
def _parse_response(self, resp: httpx.Response) -> AssessResult:
|
|
72
|
+
if resp.status_code == 402:
|
|
73
|
+
raise PaymentRequiredError
|
|
74
|
+
|
|
75
|
+
if not resp.is_success:
|
|
76
|
+
msg = f"AgentScore API returned {resp.status_code}"
|
|
77
|
+
raise RuntimeError(msg)
|
|
78
|
+
|
|
79
|
+
data: dict[str, Any] = resp.json()
|
|
80
|
+
decision = data.get("decision")
|
|
81
|
+
reasons: list[str] = data.get("decision_reasons", [])
|
|
82
|
+
allow = decision == "allow" or decision is None
|
|
83
|
+
|
|
84
|
+
return AssessResult(allow=allow, decision=decision, reasons=reasons, raw=data)
|
|
85
|
+
|
|
86
|
+
def check(self, address: str, chain: str = "base") -> AssessResult:
|
|
87
|
+
"""Synchronous assess call with caching."""
|
|
88
|
+
key = self._cache_key(address, chain)
|
|
89
|
+
|
|
90
|
+
cached = self._cache.get(key)
|
|
91
|
+
if cached is not None:
|
|
92
|
+
return cached
|
|
93
|
+
|
|
94
|
+
resp = self._sync_client.post(
|
|
95
|
+
f"{self._base_url}/v1/assess",
|
|
96
|
+
headers=self._headers(),
|
|
97
|
+
content=json.dumps(self._build_body(address, chain)),
|
|
98
|
+
)
|
|
99
|
+
result = self._parse_response(resp)
|
|
100
|
+
self._cache.set(key, result)
|
|
101
|
+
return result
|
|
102
|
+
|
|
103
|
+
async def acheck(self, address: str, chain: str = "base") -> AssessResult:
|
|
104
|
+
"""Asynchronous assess call with caching."""
|
|
105
|
+
key = self._cache_key(address, chain)
|
|
106
|
+
|
|
107
|
+
cached = self._cache.get(key)
|
|
108
|
+
if cached is not None:
|
|
109
|
+
return cached
|
|
110
|
+
|
|
111
|
+
resp = await self._async_client.post(
|
|
112
|
+
f"{self._base_url}/v1/assess",
|
|
113
|
+
headers=self._headers(),
|
|
114
|
+
content=json.dumps(self._build_body(address, chain)),
|
|
115
|
+
)
|
|
116
|
+
result = self._parse_response(resp)
|
|
117
|
+
self._cache.set(key, result)
|
|
118
|
+
return result
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class PaymentRequiredError(Exception):
|
|
122
|
+
"""Raised when the AgentScore API returns 402."""
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Django middleware for trust-gating requests using AgentScore."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from django.http import HttpRequest, JsonResponse
|
|
8
|
+
|
|
9
|
+
from agentscore_gate.client import GateClient, PaymentRequiredError
|
|
10
|
+
from agentscore_gate.types import DenialReason
|
|
11
|
+
|
|
12
|
+
DEFAULT_ADDRESS_HEADER = "HTTP_X_WALLET_ADDRESS"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AgentScoreMiddleware:
|
|
16
|
+
"""Django middleware that gates requests based on AgentScore wallet reputation.
|
|
17
|
+
|
|
18
|
+
Usage in settings.py::
|
|
19
|
+
|
|
20
|
+
MIDDLEWARE = [
|
|
21
|
+
...
|
|
22
|
+
"agentscore_gate.django.AgentScoreMiddleware",
|
|
23
|
+
...
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
AGENTSCORE_GATE = {
|
|
27
|
+
"api_key": "ask_...",
|
|
28
|
+
"min_score": 50,
|
|
29
|
+
}
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, get_response: Any) -> None:
|
|
33
|
+
from django.conf import settings
|
|
34
|
+
|
|
35
|
+
config: dict[str, Any] = getattr(settings, "AGENTSCORE_GATE", {})
|
|
36
|
+
|
|
37
|
+
self._client = GateClient(
|
|
38
|
+
api_key=config.get("api_key", ""),
|
|
39
|
+
min_grade=config.get("min_grade"),
|
|
40
|
+
min_score=config.get("min_score"),
|
|
41
|
+
require_verified_activity=config.get("require_verified_activity"),
|
|
42
|
+
fail_open=config.get("fail_open", False),
|
|
43
|
+
cache_seconds=config.get("cache_seconds", 300),
|
|
44
|
+
base_url=config.get("base_url", "https://api.agentscore.sh"),
|
|
45
|
+
)
|
|
46
|
+
self._extract_address = config.get("extract_address", self._default_extract_address)
|
|
47
|
+
self._extract_chain = config.get("extract_chain", self._default_extract_chain)
|
|
48
|
+
self._on_denied = config.get("on_denied", self._default_on_denied)
|
|
49
|
+
self.get_response = get_response
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def _default_extract_address(request: HttpRequest) -> str | None:
|
|
53
|
+
value = request.META.get(DEFAULT_ADDRESS_HEADER)
|
|
54
|
+
if value and len(value) > 0:
|
|
55
|
+
return value
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def _default_extract_chain(_request: HttpRequest) -> str | None:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def _default_on_denied(_request: HttpRequest, reason: DenialReason) -> JsonResponse:
|
|
64
|
+
body: dict[str, Any] = {"error": reason.code}
|
|
65
|
+
if reason.decision is not None:
|
|
66
|
+
body["decision"] = reason.decision
|
|
67
|
+
if reason.reasons:
|
|
68
|
+
body["reasons"] = reason.reasons
|
|
69
|
+
return JsonResponse(body, status=403)
|
|
70
|
+
|
|
71
|
+
def __call__(self, request: HttpRequest) -> Any:
|
|
72
|
+
"""Process the request."""
|
|
73
|
+
address = self._extract_address(request)
|
|
74
|
+
|
|
75
|
+
if not address:
|
|
76
|
+
if self._client.fail_open:
|
|
77
|
+
return self.get_response(request)
|
|
78
|
+
return self._on_denied(request, DenialReason(code="missing_wallet_address"))
|
|
79
|
+
|
|
80
|
+
chain = self._extract_chain(request) or "base"
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
result = self._client.check(address, chain)
|
|
84
|
+
|
|
85
|
+
if result.allow:
|
|
86
|
+
request.agentscore = result.raw # type: ignore[attr-defined]
|
|
87
|
+
return self.get_response(request)
|
|
88
|
+
|
|
89
|
+
reason = DenialReason(code="wallet_not_trusted", decision=result.decision, reasons=result.reasons)
|
|
90
|
+
return self._on_denied(request, reason)
|
|
91
|
+
except PaymentRequiredError:
|
|
92
|
+
if self._client.fail_open:
|
|
93
|
+
return self.get_response(request)
|
|
94
|
+
return self._on_denied(request, DenialReason(code="payment_required"))
|
|
95
|
+
except Exception:
|
|
96
|
+
if self._client.fail_open:
|
|
97
|
+
return self.get_response(request)
|
|
98
|
+
return self._on_denied(request, DenialReason(code="api_error"))
|
agentscore_gate/flask.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Flask integration for trust-gating requests using AgentScore."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from agentscore_gate.client import GateClient, PaymentRequiredError
|
|
8
|
+
from agentscore_gate.types import DenialReason, Grade
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
|
|
13
|
+
from flask import Flask, Request, Response
|
|
14
|
+
|
|
15
|
+
DEFAULT_ADDRESS_HEADER = "x-wallet-address"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _default_extract_address(request: Request) -> str | None:
|
|
19
|
+
value = request.headers.get(DEFAULT_ADDRESS_HEADER)
|
|
20
|
+
if value and len(value) > 0:
|
|
21
|
+
return value
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _default_extract_chain(_request: Request) -> str | None:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _default_on_denied(_request: Request, reason: DenialReason) -> tuple[dict[str, Any], int]:
|
|
30
|
+
body: dict[str, Any] = {"error": reason.code}
|
|
31
|
+
if reason.decision is not None:
|
|
32
|
+
body["decision"] = reason.decision
|
|
33
|
+
if reason.reasons:
|
|
34
|
+
body["reasons"] = reason.reasons
|
|
35
|
+
return body, 403
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def agentscore_gate(
|
|
39
|
+
app: Flask,
|
|
40
|
+
*,
|
|
41
|
+
api_key: str,
|
|
42
|
+
min_grade: Grade | None = None,
|
|
43
|
+
min_score: int | None = None,
|
|
44
|
+
require_verified_activity: bool | None = None,
|
|
45
|
+
fail_open: bool = False,
|
|
46
|
+
cache_seconds: int = 300,
|
|
47
|
+
base_url: str = "https://api.agentscore.sh",
|
|
48
|
+
extract_address: Callable[[Request], str | None] | None = None,
|
|
49
|
+
extract_chain: Callable[[Request], str | None] | None = None,
|
|
50
|
+
on_denied: Callable[[Request, DenialReason], tuple[dict[str, Any], int]] | None = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Register AgentScore gate as a Flask before_request handler.
|
|
53
|
+
|
|
54
|
+
Usage::
|
|
55
|
+
|
|
56
|
+
from flask import Flask
|
|
57
|
+
from agentscore_gate.flask import agentscore_gate
|
|
58
|
+
|
|
59
|
+
app = Flask(__name__)
|
|
60
|
+
agentscore_gate(app, api_key="ask_...", min_score=50)
|
|
61
|
+
"""
|
|
62
|
+
from flask import g, jsonify
|
|
63
|
+
from flask import request as flask_request
|
|
64
|
+
|
|
65
|
+
client = GateClient(
|
|
66
|
+
api_key=api_key,
|
|
67
|
+
min_grade=min_grade,
|
|
68
|
+
min_score=min_score,
|
|
69
|
+
require_verified_activity=require_verified_activity,
|
|
70
|
+
fail_open=fail_open,
|
|
71
|
+
cache_seconds=cache_seconds,
|
|
72
|
+
base_url=base_url,
|
|
73
|
+
)
|
|
74
|
+
_extract_address = extract_address or _default_extract_address
|
|
75
|
+
_extract_chain = extract_chain or _default_extract_chain
|
|
76
|
+
_on_denied = on_denied or _default_on_denied
|
|
77
|
+
|
|
78
|
+
@app.before_request
|
|
79
|
+
def _agentscore_check() -> Response | None:
|
|
80
|
+
address = _extract_address(flask_request)
|
|
81
|
+
if not address:
|
|
82
|
+
if client.fail_open:
|
|
83
|
+
return None
|
|
84
|
+
try:
|
|
85
|
+
body, status = _on_denied(flask_request, DenialReason(code="missing_wallet_address"))
|
|
86
|
+
except (TypeError, ValueError) as exc:
|
|
87
|
+
msg = "on_denied must return a (dict, int) tuple, e.g. ({'error': 'denied'}, 403)"
|
|
88
|
+
raise TypeError(msg) from exc
|
|
89
|
+
return jsonify(body), status
|
|
90
|
+
|
|
91
|
+
chain = _extract_chain(flask_request) or "base"
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
result = client.check(address, chain)
|
|
95
|
+
|
|
96
|
+
if result.allow:
|
|
97
|
+
g.agentscore = result.raw
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
reason = DenialReason(code="wallet_not_trusted", decision=result.decision, reasons=result.reasons)
|
|
101
|
+
try:
|
|
102
|
+
body, status = _on_denied(flask_request, reason)
|
|
103
|
+
except (TypeError, ValueError) as exc:
|
|
104
|
+
msg = "on_denied must return a (dict, int) tuple, e.g. ({'error': 'denied'}, 403)"
|
|
105
|
+
raise TypeError(msg) from exc
|
|
106
|
+
return jsonify(body), status
|
|
107
|
+
except PaymentRequiredError:
|
|
108
|
+
if client.fail_open:
|
|
109
|
+
return None
|
|
110
|
+
try:
|
|
111
|
+
body, status = _on_denied(flask_request, DenialReason(code="payment_required"))
|
|
112
|
+
except (TypeError, ValueError) as exc:
|
|
113
|
+
msg = "on_denied must return a (dict, int) tuple, e.g. ({'error': 'denied'}, 403)"
|
|
114
|
+
raise TypeError(msg) from exc
|
|
115
|
+
return jsonify(body), status
|
|
116
|
+
except TypeError:
|
|
117
|
+
raise
|
|
118
|
+
except Exception:
|
|
119
|
+
if client.fail_open:
|
|
120
|
+
return None
|
|
121
|
+
try:
|
|
122
|
+
body, status = _on_denied(flask_request, DenialReason(code="api_error"))
|
|
123
|
+
except (TypeError, ValueError) as exc:
|
|
124
|
+
msg = "on_denied must return a (dict, int) tuple, e.g. ({'error': 'denied'}, 403)"
|
|
125
|
+
raise TypeError(msg) from exc
|
|
126
|
+
return jsonify(body), status
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""ASGI middleware for trust-gating requests using AgentScore."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from starlette.requests import Request
|
|
8
|
+
from starlette.responses import JSONResponse
|
|
9
|
+
|
|
10
|
+
from agentscore_gate.client import GateClient, PaymentRequiredError
|
|
11
|
+
from agentscore_gate.types import DenialReason, Grade
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Awaitable, Callable
|
|
15
|
+
|
|
16
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
17
|
+
|
|
18
|
+
DEFAULT_ADDRESS_HEADER = "x-wallet-address"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _default_extract_address(request: Request) -> str | None:
|
|
22
|
+
value = request.headers.get(DEFAULT_ADDRESS_HEADER)
|
|
23
|
+
if value and len(value) > 0:
|
|
24
|
+
return value
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _default_extract_chain(_request: Request) -> str | None:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def _default_on_denied(_request: Request, reason: DenialReason) -> JSONResponse:
|
|
33
|
+
body: dict[str, Any] = {"error": reason.code}
|
|
34
|
+
if reason.decision is not None:
|
|
35
|
+
body["decision"] = reason.decision
|
|
36
|
+
if reason.reasons:
|
|
37
|
+
body["reasons"] = reason.reasons
|
|
38
|
+
return JSONResponse(body, status_code=403)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AgentScoreGate:
|
|
42
|
+
"""ASGI middleware that gates requests based on AgentScore wallet reputation.
|
|
43
|
+
|
|
44
|
+
Usage with Starlette / FastAPI::
|
|
45
|
+
|
|
46
|
+
app.add_middleware(
|
|
47
|
+
AgentScoreGate,
|
|
48
|
+
api_key="ask_...",
|
|
49
|
+
min_score=50,
|
|
50
|
+
)
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
app: ASGIApp,
|
|
56
|
+
*,
|
|
57
|
+
api_key: str,
|
|
58
|
+
min_grade: Grade | None = None,
|
|
59
|
+
min_score: int | None = None,
|
|
60
|
+
require_verified_activity: bool | None = None,
|
|
61
|
+
fail_open: bool = False,
|
|
62
|
+
cache_seconds: int = 300,
|
|
63
|
+
base_url: str = "https://api.agentscore.sh",
|
|
64
|
+
extract_address: Callable[[Request], str | None] | None = None,
|
|
65
|
+
extract_chain: Callable[[Request], str | None] | None = None,
|
|
66
|
+
on_denied: Callable[[Request, DenialReason], Awaitable[JSONResponse]] | None = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
self.app = app
|
|
69
|
+
self._client = GateClient(
|
|
70
|
+
api_key=api_key,
|
|
71
|
+
min_grade=min_grade,
|
|
72
|
+
min_score=min_score,
|
|
73
|
+
require_verified_activity=require_verified_activity,
|
|
74
|
+
fail_open=fail_open,
|
|
75
|
+
cache_seconds=cache_seconds,
|
|
76
|
+
base_url=base_url,
|
|
77
|
+
)
|
|
78
|
+
self._extract_address = extract_address or _default_extract_address
|
|
79
|
+
self._extract_chain = extract_chain or _default_extract_chain
|
|
80
|
+
self._on_denied = on_denied or _default_on_denied
|
|
81
|
+
|
|
82
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
83
|
+
"""ASGI entry point."""
|
|
84
|
+
if scope["type"] != "http":
|
|
85
|
+
await self.app(scope, receive, send)
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
request = Request(scope, receive, send)
|
|
89
|
+
|
|
90
|
+
address = self._extract_address(request)
|
|
91
|
+
if not address:
|
|
92
|
+
if self._client.fail_open:
|
|
93
|
+
await self.app(scope, receive, send)
|
|
94
|
+
return
|
|
95
|
+
reason = DenialReason(code="missing_wallet_address")
|
|
96
|
+
response = await self._on_denied(request, reason)
|
|
97
|
+
await response(scope, receive, send)
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
chain = self._extract_chain(request) or "base"
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
result = await self._client.acheck(address, chain)
|
|
104
|
+
|
|
105
|
+
if result.allow:
|
|
106
|
+
scope["state"] = {**scope.get("state", {}), "agentscore": result.raw}
|
|
107
|
+
await self.app(scope, receive, send)
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
reason = DenialReason(code="wallet_not_trusted", decision=result.decision, reasons=result.reasons)
|
|
111
|
+
response = await self._on_denied(request, reason)
|
|
112
|
+
await response(scope, receive, send)
|
|
113
|
+
except PaymentRequiredError:
|
|
114
|
+
if self._client.fail_open:
|
|
115
|
+
await self.app(scope, receive, send)
|
|
116
|
+
return
|
|
117
|
+
reason = DenialReason(code="payment_required")
|
|
118
|
+
response = await self._on_denied(request, reason)
|
|
119
|
+
await response(scope, receive, send)
|
|
120
|
+
except Exception:
|
|
121
|
+
if self._client.fail_open:
|
|
122
|
+
await self.app(scope, receive, send)
|
|
123
|
+
return
|
|
124
|
+
reason = DenialReason(code="api_error")
|
|
125
|
+
response = await self._on_denied(request, reason)
|
|
126
|
+
await response(scope, receive, send)
|
agentscore_gate/py.typed
ADDED
|
File without changes
|
agentscore_gate/types.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
Grade = Literal["A", "B", "C", "D", "F"]
|
|
7
|
+
|
|
8
|
+
DenialCode = Literal["wallet_not_trusted", "missing_wallet_address", "api_error", "payment_required"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class DenialReason:
|
|
13
|
+
"""Reason a request was denied by the gate middleware."""
|
|
14
|
+
|
|
15
|
+
code: DenialCode
|
|
16
|
+
decision: str | None = None
|
|
17
|
+
reasons: list[str] = field(default_factory=list)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class AssessResult:
|
|
22
|
+
"""Internal result from the AgentScore assess API."""
|
|
23
|
+
|
|
24
|
+
allow: bool
|
|
25
|
+
decision: str | None = None
|
|
26
|
+
reasons: list[str] = field(default_factory=list)
|
|
27
|
+
raw: dict[str, Any] | None = None
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentscore-gate
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Trust-gating middleware for Python web frameworks using AgentScore
|
|
5
|
+
Project-URL: Homepage, https://agentscore.sh
|
|
6
|
+
Project-URL: Repository, https://github.com/agentscore/python-gate
|
|
7
|
+
Project-URL: Issues, https://github.com/agentscore/python-gate/issues
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: agent,agentic-payments,agentscore,ai-agent,blockchain,django,erc-8004,fastapi,flask,gate,middleware,reputation,starlette,trust,wallet,x402
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Framework :: AsyncIO
|
|
13
|
+
Classifier: Framework :: Django
|
|
14
|
+
Classifier: Framework :: FastAPI
|
|
15
|
+
Classifier: Framework :: Flask
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: httpx<1.0.0,>=0.25.0
|
|
22
|
+
Provides-Extra: django
|
|
23
|
+
Requires-Dist: django>=4.0; extra == 'django'
|
|
24
|
+
Provides-Extra: fastapi
|
|
25
|
+
Requires-Dist: starlette>=0.27.0; extra == 'fastapi'
|
|
26
|
+
Provides-Extra: flask
|
|
27
|
+
Requires-Dist: flask>=2.0.0; extra == 'flask'
|
|
28
|
+
Provides-Extra: starlette
|
|
29
|
+
Requires-Dist: starlette>=0.27.0; extra == 'starlette'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# agentscore-gate
|
|
33
|
+
|
|
34
|
+
[](https://pypi.org/project/agentscore-gate/)
|
|
35
|
+
[](LICENSE)
|
|
36
|
+
|
|
37
|
+
ASGI middleware for trust-gating requests using [AgentScore](https://agentscore.sh). Verify AI agent wallet reputation before allowing requests through, built for the [x402](https://github.com/coinbase/x402) payment ecosystem and [ERC-8004](https://eips.ethereum.org/EIPS/eip-8004) agent registry. Works with FastAPI, Starlette, and any ASGI framework.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install agentscore-gate
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Quick Start
|
|
46
|
+
|
|
47
|
+
### FastAPI
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from fastapi import FastAPI
|
|
51
|
+
from agentscore_gate import AgentScoreGate
|
|
52
|
+
|
|
53
|
+
app = FastAPI()
|
|
54
|
+
app.add_middleware(AgentScoreGate, api_key="ask_...", min_score=50)
|
|
55
|
+
|
|
56
|
+
@app.get("/")
|
|
57
|
+
async def root():
|
|
58
|
+
return {"message": "Hello, trusted agent!"}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Starlette
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from starlette.applications import Starlette
|
|
65
|
+
from starlette.responses import PlainTextResponse
|
|
66
|
+
from starlette.routing import Route
|
|
67
|
+
from agentscore_gate import AgentScoreGate
|
|
68
|
+
|
|
69
|
+
async def homepage(request):
|
|
70
|
+
agentscore_data = request.state.agentscore
|
|
71
|
+
return PlainTextResponse("Hello, trusted agent!")
|
|
72
|
+
|
|
73
|
+
app = Starlette(routes=[Route("/", homepage)])
|
|
74
|
+
app.add_middleware(AgentScoreGate, api_key="ask_...", min_score=50)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Options
|
|
78
|
+
|
|
79
|
+
| Parameter | Type | Default | Description |
|
|
80
|
+
|-----------|------|---------|-------------|
|
|
81
|
+
| `api_key` | `str` | *required* | API key from [agentscore.sh](https://agentscore.sh) |
|
|
82
|
+
| `min_score` | `int \| None` | `None` | Minimum score (0–100) |
|
|
83
|
+
| `min_grade` | `str \| None` | `None` | Minimum grade (A–F) |
|
|
84
|
+
| `require_verified_activity` | `bool \| None` | `None` | Require verified payment activity |
|
|
85
|
+
| `fail_open` | `bool` | `False` | Allow requests when API is unreachable |
|
|
86
|
+
| `cache_seconds` | `int` | `300` | Cache TTL for results |
|
|
87
|
+
| `base_url` | `str` | `https://api.agentscore.sh` | API base URL |
|
|
88
|
+
| `extract_address` | `callable` | Reads `x-wallet-address` header | Custom address extractor |
|
|
89
|
+
| `on_denied` | `async callable` | Returns 403 JSON | Custom denial handler |
|
|
90
|
+
|
|
91
|
+
## How It Works
|
|
92
|
+
|
|
93
|
+
1. Extracts wallet address from request header (`x-wallet-address`)
|
|
94
|
+
2. Checks in-memory cache for a previous result
|
|
95
|
+
3. Calls AgentScore `/v1/assess` with your policy
|
|
96
|
+
4. Allows or blocks based on the decision
|
|
97
|
+
5. Attaches data to `request.state.agentscore` on allowed requests
|
|
98
|
+
|
|
99
|
+
## Documentation
|
|
100
|
+
|
|
101
|
+
- [API Reference](https://docs.agentscore.sh)
|
|
102
|
+
- [ERC-8004 Standard](https://eips.ethereum.org/EIPS/eip-8004)
|
|
103
|
+
- [x402 Protocol](https://github.com/coinbase/x402)
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
agentscore_gate/__init__.py,sha256=juN16XA46sg7hhZE_yz8U82It49yIZhZTdB2XStpcDE,689
|
|
2
|
+
agentscore_gate/cache.py,sha256=OE5Aqc-pNSU8I06ZB-oDI4uT_yPGI0pIoDFJfNiaT7I,1989
|
|
3
|
+
agentscore_gate/client.py,sha256=P_QiDRq4hoWReOOQhod7-28PngZhQJwXqyzlKcgvbns,4022
|
|
4
|
+
agentscore_gate/django.py,sha256=WMLKn2OBU9NwAT2f80qTm3vapHPRY7W4MDf5zanzFBw,3532
|
|
5
|
+
agentscore_gate/flask.py,sha256=GFD5YwB5FRDAIPhkj91OtBb2p_Gr09QxrY_6mFaIwA4,4465
|
|
6
|
+
agentscore_gate/middleware.py,sha256=Weyu9VkHo-INU7VtcBrjB-PJESSbva51268DI90nJnU,4356
|
|
7
|
+
agentscore_gate/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
agentscore_gate/types.py,sha256=0aP7HuowGgEHtAeWkfZXW3IejA6-vAGecuUQCI9I-4Y,687
|
|
9
|
+
agentscore_gate-1.0.0.dist-info/METADATA,sha256=PzlK2PzqM_Xurl22YfM-IFf7SMo35YGs_b_mwckuMNA,3923
|
|
10
|
+
agentscore_gate-1.0.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
11
|
+
agentscore_gate-1.0.0.dist-info/licenses/LICENSE,sha256=bwu2k82ka0IA05vhtYU4O1oc4tdPEv1FYLdwyxhCAx8,1067
|
|
12
|
+
agentscore_gate-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AgentScore
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|