railgate 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.
- policy_gate/MANIFEST.sha256 +3 -0
- policy_gate/__init__.py +7 -0
- policy_gate/factory.py +80 -0
- policy_gate/gate.py +273 -0
- policy_gate/policy.py +174 -0
- policy_gate/store.py +152 -0
- railgate/__init__.py +19 -0
- railgate/adapters/__init__.py +6 -0
- railgate/adapters/base.py +90 -0
- railgate/adapters/evm.py +110 -0
- railgate/adapters/xrpl.py +133 -0
- railgate/facade.py +138 -0
- railgate/live.py +53 -0
- railgate/oracle.py +91 -0
- railgate-1.0.0.dist-info/METADATA +105 -0
- railgate-1.0.0.dist-info/RECORD +19 -0
- railgate-1.0.0.dist-info/WHEEL +5 -0
- railgate-1.0.0.dist-info/licenses/LICENSE +26 -0
- railgate-1.0.0.dist-info/top_level.txt +2 -0
policy_gate/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Pre-signing payment policy gate for x402 buyer agents (EXP-001)."""
|
|
2
|
+
from .factory import GatedClient, create_gated_client
|
|
3
|
+
from .gate import PolicyGate, PolicyRefusal
|
|
4
|
+
from .policy import PaymentPolicy, PolicyError
|
|
5
|
+
|
|
6
|
+
__all__ = ["GatedClient", "create_gated_client", "PolicyGate", "PolicyRefusal",
|
|
7
|
+
"PaymentPolicy", "PolicyError"]
|
policy_gate/factory.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Mandatory-construction factory for gated x402 clients (EXP-001).
|
|
2
|
+
|
|
3
|
+
AgentBadge's evidence line is the spec: "a cap you can forget is a cap you
|
|
4
|
+
will forget." So there is no code path here that builds a paying client
|
|
5
|
+
without a policy — create_gated_client(policy=None) raises TypeError with
|
|
6
|
+
the same shape AgentBadge used: refusing to create an uncapped paying
|
|
7
|
+
client. The vendored SDK's own SpendControls are ALSO set from the same
|
|
8
|
+
policy (defense in depth): the SDK filters non-allowlisted assets and
|
|
9
|
+
over-cap accepts during selection, and this gate enforces the stateful
|
|
10
|
+
layer (budgets, counterparty, velocity, intent, replay) at the
|
|
11
|
+
pre-signing hook.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
from x402 import x402Client
|
|
18
|
+
from x402.mechanisms.evm.exact import ExactEvmScheme
|
|
19
|
+
from x402.schemas import PaymentAbortedError
|
|
20
|
+
|
|
21
|
+
from .gate import PolicyGate, PolicyRefusal
|
|
22
|
+
from .policy import PaymentPolicy, usd_money_string
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class GatedClient:
|
|
27
|
+
client: x402Client
|
|
28
|
+
gate: PolicyGate
|
|
29
|
+
policy: PaymentPolicy
|
|
30
|
+
|
|
31
|
+
async def pay(self, payment_required, *, intent: str | None = None,
|
|
32
|
+
expected_pay_to: str | None = None,
|
|
33
|
+
expected_resource: str | None = None):
|
|
34
|
+
"""Create a payment payload through the gate.
|
|
35
|
+
|
|
36
|
+
Declares intent first when given. On policy denial raises
|
|
37
|
+
PolicyRefusal with the structured refusal dict (never a bare
|
|
38
|
+
exception string to parse).
|
|
39
|
+
"""
|
|
40
|
+
if intent is not None:
|
|
41
|
+
self.gate.declare_intent(intent, expected_pay_to=expected_pay_to,
|
|
42
|
+
expected_resource=expected_resource)
|
|
43
|
+
self.gate.last_refusal = None
|
|
44
|
+
try:
|
|
45
|
+
return await self.client.create_payment_payload(payment_required)
|
|
46
|
+
except PaymentAbortedError:
|
|
47
|
+
if self.gate.last_refusal is not None:
|
|
48
|
+
raise PolicyRefusal(self.gate.last_refusal) from None
|
|
49
|
+
raise
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def create_gated_client(policy: PaymentPolicy | None, signer, *,
|
|
53
|
+
store_path: str, audit_path: str | None = None,
|
|
54
|
+
clock=None) -> GatedClient:
|
|
55
|
+
if policy is None:
|
|
56
|
+
raise TypeError("PaymentPolicy required — refusing to create an uncapped paying client")
|
|
57
|
+
if signer is None:
|
|
58
|
+
raise TypeError("signer required — refusing to create a paying client without a signer")
|
|
59
|
+
gate = PolicyGate(policy, store_path, audit_path=audit_path, clock=clock)
|
|
60
|
+
client = x402Client()
|
|
61
|
+
for network in policy.allowed_networks:
|
|
62
|
+
if ":*" in network:
|
|
63
|
+
raise ValueError("factory needs concrete networks to register schemes; "
|
|
64
|
+
"wildcard network patterns are enforced by the gate but "
|
|
65
|
+
"cannot be registered — list networks explicitly")
|
|
66
|
+
client.register(network, ExactEvmScheme(signer=signer))
|
|
67
|
+
allowed_assets = [
|
|
68
|
+
{"network": network, "asset": asset,
|
|
69
|
+
"max_amount_per_payment": str(policy.per_call_cap_atomic)}
|
|
70
|
+
for network in policy.allowed_networks
|
|
71
|
+
for asset in policy.allowed_assets
|
|
72
|
+
]
|
|
73
|
+
client.set_spend_controls({
|
|
74
|
+
"max_amount_per_payment": usd_money_string(policy.per_call_cap_atomic, policy.asset_decimals),
|
|
75
|
+
"allowed_assets": allowed_assets,
|
|
76
|
+
})
|
|
77
|
+
client.on_before_payment_creation(gate.before_hook)
|
|
78
|
+
client.on_after_payment_creation(gate.after_hook)
|
|
79
|
+
client.on_payment_creation_failure(gate.failure_hook)
|
|
80
|
+
return GatedClient(client=client, gate=gate, policy=policy)
|
policy_gate/gate.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""PolicyGate — the pre-signing enforcement layer (EXP-001).
|
|
2
|
+
|
|
3
|
+
Sits on the REAL x402 client's payment-creation hooks:
|
|
4
|
+
|
|
5
|
+
before-hook -> evaluate policy; on deny return AbortResult (the client
|
|
6
|
+
raises PaymentAbortedError and the scheme/signer is NEVER
|
|
7
|
+
invoked, so no signature exists); on allow, atomically
|
|
8
|
+
reserve budget in the SpendStore.
|
|
9
|
+
after-hook -> commit the reservation (a signature now exists).
|
|
10
|
+
failure-hook -> release the reservation (creation failed downstream).
|
|
11
|
+
|
|
12
|
+
Every decision (allow and deny) is appended to an audit JSONL. Every denial
|
|
13
|
+
builds a structured refusal {code, detail, amount, pay_to, network,
|
|
14
|
+
suggested_action, ...} — MEMO-003 Q3: the caller never parses prose.
|
|
15
|
+
|
|
16
|
+
Refusal actions:
|
|
17
|
+
do_not_retry — same request can never pass (allowlist, mismatch)
|
|
18
|
+
escalate_to_operator — a human/policy change is the way out (budgets, caps)
|
|
19
|
+
degrade_or_skip — try a cheaper resource or proceed without it
|
|
20
|
+
retry_after_window — velocity / replay window; wait, then retry
|
|
21
|
+
declare_intent_and_retry — intent was missing; declare it and retry
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import hashlib
|
|
26
|
+
import json
|
|
27
|
+
import threading
|
|
28
|
+
import time
|
|
29
|
+
import uuid
|
|
30
|
+
from collections import defaultdict
|
|
31
|
+
from datetime import datetime, timezone
|
|
32
|
+
|
|
33
|
+
from x402.schemas import AbortResult
|
|
34
|
+
|
|
35
|
+
from .policy import PaymentPolicy, parse_atomic_amount
|
|
36
|
+
from .store import SpendStore, StoreError
|
|
37
|
+
|
|
38
|
+
_ACTION = {
|
|
39
|
+
"PER_CALL_CAP": "degrade_or_skip",
|
|
40
|
+
"SESSION_BUDGET": "escalate_to_operator",
|
|
41
|
+
"DAILY_BUDGET": "escalate_to_operator",
|
|
42
|
+
"COUNTERPARTY_CAP": "escalate_to_operator",
|
|
43
|
+
"COUNTERPARTY_NOT_ALLOWED": "escalate_to_operator",
|
|
44
|
+
"NETWORK_NOT_ALLOWED": "do_not_retry",
|
|
45
|
+
"ASSET_NOT_ALLOWED": "do_not_retry",
|
|
46
|
+
"VELOCITY": "retry_after_window",
|
|
47
|
+
"REPLAY_DUPLICATE": "retry_after_window",
|
|
48
|
+
"INTENT_REQUIRED": "declare_intent_and_retry",
|
|
49
|
+
"INTENT_MISMATCH": "do_not_retry",
|
|
50
|
+
"MALFORMED_AMOUNT": "do_not_retry",
|
|
51
|
+
"UNSUPPORTED_VERSION": "do_not_retry",
|
|
52
|
+
"INTERNAL_FAIL_CLOSED": "escalate_to_operator",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class PolicyRefusal(Exception):
|
|
57
|
+
"""Structured refusal raised by GatedClient.pay(). Carries the refusal dict."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, refusal: dict):
|
|
60
|
+
self.refusal = refusal
|
|
61
|
+
super().__init__(f"{refusal.get('code')}: {refusal.get('detail')}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class PolicyGate:
|
|
65
|
+
def __init__(self, policy: PaymentPolicy, store_path: str,
|
|
66
|
+
audit_path: str | None = None, clock=None):
|
|
67
|
+
if policy is None:
|
|
68
|
+
raise TypeError("PaymentPolicy required — refusing to create an uncapped paying client")
|
|
69
|
+
self.policy = policy
|
|
70
|
+
self._clock = clock or time.time
|
|
71
|
+
self.session_id = uuid.uuid4().hex
|
|
72
|
+
self.store = SpendStore(store_path, self.session_id)
|
|
73
|
+
self._audit_path = audit_path
|
|
74
|
+
self._audit_lock = threading.Lock()
|
|
75
|
+
self._pending: dict[str, list[int]] = defaultdict(list)
|
|
76
|
+
self._pending_lock = threading.Lock()
|
|
77
|
+
# Intent state: a declaration authorizes payments that match it.
|
|
78
|
+
# Each declare_intent() bumps the declaration counter; the counter is
|
|
79
|
+
# part of the payment fingerprint, so re-declaring intent is what
|
|
80
|
+
# makes a deliberate repeat purchase distinguishable from a loop
|
|
81
|
+
# replaying one authorization forever (CARD-003 loss mode #1).
|
|
82
|
+
self._intent_text: str | None = None
|
|
83
|
+
self._intent_hash: str | None = None
|
|
84
|
+
self._intent_pay_to: str | None = None
|
|
85
|
+
self._intent_resource: str | None = None
|
|
86
|
+
self._intent_decl_id = 0
|
|
87
|
+
self.last_refusal: dict | None = None
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------- intent
|
|
90
|
+
def declare_intent(self, text: str, *, expected_pay_to: str | None = None,
|
|
91
|
+
expected_resource: str | None = None) -> int:
|
|
92
|
+
"""Declare what the next payment(s) are FOR. Returns declaration id.
|
|
93
|
+
|
|
94
|
+
expected_pay_to / expected_resource bind the intent: a payment whose
|
|
95
|
+
counterparty or resource differs is refused INTENT_MISMATCH — the
|
|
96
|
+
signature is bound to the declared purpose before it exists.
|
|
97
|
+
"""
|
|
98
|
+
if not isinstance(text, str) or not text.strip():
|
|
99
|
+
raise ValueError("intent text must be a non-empty string")
|
|
100
|
+
self._intent_text = text
|
|
101
|
+
self._intent_hash = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
102
|
+
self._intent_pay_to = expected_pay_to.lower() if expected_pay_to else None
|
|
103
|
+
self._intent_resource = expected_resource
|
|
104
|
+
self._intent_decl_id += 1
|
|
105
|
+
return self._intent_decl_id
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------- helpers
|
|
108
|
+
def _now(self) -> float:
|
|
109
|
+
return float(self._clock())
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def _day_start(now: float) -> float:
|
|
113
|
+
dt = datetime.fromtimestamp(now, tz=timezone.utc)
|
|
114
|
+
return dt.replace(hour=0, minute=0, second=0, microsecond=0).timestamp()
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
def _fingerprint(*, intent_hash: str | None, decl_id: int, network: str,
|
|
118
|
+
asset: str, pay_to: str, amount: int, resource_url: str | None) -> str:
|
|
119
|
+
raw = "|".join([intent_hash or "", str(decl_id), network, asset.lower(),
|
|
120
|
+
pay_to.lower(), str(amount), resource_url or ""])
|
|
121
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
122
|
+
|
|
123
|
+
def _audit(self, record: dict) -> None:
|
|
124
|
+
if not self._audit_path:
|
|
125
|
+
return
|
|
126
|
+
record = {"ts": self._now(), "session_id": self.session_id, **record}
|
|
127
|
+
line = json.dumps(record, sort_keys=True)
|
|
128
|
+
with self._audit_lock:
|
|
129
|
+
with open(self._audit_path, "a", encoding="utf-8") as f:
|
|
130
|
+
f.write(line + "\n")
|
|
131
|
+
|
|
132
|
+
def _deny(self, code: str, detail: str, *, amount=None, pay_to=None,
|
|
133
|
+
network=None, resource_url=None) -> AbortResult:
|
|
134
|
+
refusal = {
|
|
135
|
+
"code": code,
|
|
136
|
+
"detail": detail,
|
|
137
|
+
"suggested_action": _ACTION[code],
|
|
138
|
+
"amount_atomic": amount,
|
|
139
|
+
"pay_to": pay_to,
|
|
140
|
+
"network": network,
|
|
141
|
+
"resource_url": resource_url,
|
|
142
|
+
"session_id": self.session_id,
|
|
143
|
+
}
|
|
144
|
+
if code in ("VELOCITY", "REPLAY_DUPLICATE"):
|
|
145
|
+
refusal["retry_after_seconds"] = (
|
|
146
|
+
self.policy.window_seconds if code == "VELOCITY"
|
|
147
|
+
else self.policy.replay_window_seconds)
|
|
148
|
+
self.last_refusal = refusal
|
|
149
|
+
self._audit({"decision": "deny", **refusal})
|
|
150
|
+
return AbortResult(reason="policy_refusal:" + json.dumps(refusal, sort_keys=True))
|
|
151
|
+
|
|
152
|
+
# ---------------------------------------------------------------- hooks
|
|
153
|
+
def before_hook(self, ctx) -> AbortResult | None:
|
|
154
|
+
"""x402 on_before_payment_creation hook. None = allow."""
|
|
155
|
+
req = ctx.selected_requirements
|
|
156
|
+
payment_required = ctx.payment_required
|
|
157
|
+
resource_url = None
|
|
158
|
+
if getattr(payment_required, "resource", None) is not None:
|
|
159
|
+
resource_url = payment_required.resource.url
|
|
160
|
+
try:
|
|
161
|
+
amount_raw = req.get_amount() if hasattr(req, "get_amount") else getattr(req, "amount", None)
|
|
162
|
+
pay_to = getattr(req, "pay_to", None)
|
|
163
|
+
network = getattr(req, "network", None)
|
|
164
|
+
asset = getattr(req, "asset", None)
|
|
165
|
+
if pay_to is None or network is None or asset is None:
|
|
166
|
+
return self._deny("UNSUPPORTED_VERSION",
|
|
167
|
+
"requirements shape not supported by the gate (V2 required)",
|
|
168
|
+
resource_url=resource_url)
|
|
169
|
+
amount = parse_atomic_amount(amount_raw)
|
|
170
|
+
if amount is None:
|
|
171
|
+
return self._deny("MALFORMED_AMOUNT",
|
|
172
|
+
f"amount {amount_raw!r} is not an integer atomic amount",
|
|
173
|
+
pay_to=pay_to, network=network, resource_url=resource_url)
|
|
174
|
+
p = self.policy
|
|
175
|
+
if not p.network_allowed(str(network)):
|
|
176
|
+
return self._deny("NETWORK_NOT_ALLOWED", f"network {network} not in policy allowlist",
|
|
177
|
+
amount=amount, pay_to=pay_to, network=network, resource_url=resource_url)
|
|
178
|
+
if not p.asset_allowed(str(asset)):
|
|
179
|
+
return self._deny("ASSET_NOT_ALLOWED", f"asset {asset} not in policy allowlist",
|
|
180
|
+
amount=amount, pay_to=pay_to, network=network, resource_url=resource_url)
|
|
181
|
+
if not p.counterparty_allowed(str(pay_to)):
|
|
182
|
+
return self._deny("COUNTERPARTY_NOT_ALLOWED", f"pay_to {pay_to} not in counterparty allowlist",
|
|
183
|
+
amount=amount, pay_to=pay_to, network=network, resource_url=resource_url)
|
|
184
|
+
if amount > p.per_call_cap_atomic:
|
|
185
|
+
return self._deny("PER_CALL_CAP",
|
|
186
|
+
f"amount {amount} exceeds per-call cap {p.per_call_cap_atomic}",
|
|
187
|
+
amount=amount, pay_to=pay_to, network=network, resource_url=resource_url)
|
|
188
|
+
if amount <= 0:
|
|
189
|
+
return self._deny("MALFORMED_AMOUNT", "amount must be positive",
|
|
190
|
+
amount=amount, pay_to=pay_to, network=network, resource_url=resource_url)
|
|
191
|
+
if p.intent_required and self._intent_hash is None:
|
|
192
|
+
return self._deny("INTENT_REQUIRED", "no intent declared for this payment",
|
|
193
|
+
amount=amount, pay_to=pay_to, network=network, resource_url=resource_url)
|
|
194
|
+
if self._intent_pay_to is not None and str(pay_to).lower() != self._intent_pay_to:
|
|
195
|
+
return self._deny("INTENT_MISMATCH",
|
|
196
|
+
f"payment pay_to {pay_to} does not match intent-bound counterparty {self._intent_pay_to}",
|
|
197
|
+
amount=amount, pay_to=pay_to, network=network, resource_url=resource_url)
|
|
198
|
+
if self._intent_resource is not None and resource_url != self._intent_resource:
|
|
199
|
+
return self._deny("INTENT_MISMATCH",
|
|
200
|
+
f"payment resource {resource_url} does not match intent-bound resource {self._intent_resource}",
|
|
201
|
+
amount=amount, pay_to=pay_to, network=network, resource_url=resource_url)
|
|
202
|
+
now = self._now()
|
|
203
|
+
fingerprint = self._fingerprint(
|
|
204
|
+
intent_hash=self._intent_hash, decl_id=self._intent_decl_id,
|
|
205
|
+
network=str(network), asset=str(asset), pay_to=str(pay_to),
|
|
206
|
+
amount=amount, resource_url=resource_url)
|
|
207
|
+
status, value = self.store.check_and_reserve(
|
|
208
|
+
fingerprint=fingerprint, intent_hash=self._intent_hash,
|
|
209
|
+
amount=amount, pay_to=str(pay_to), network=str(network),
|
|
210
|
+
asset=str(asset), resource_url=resource_url, now=now,
|
|
211
|
+
day_start=self._day_start(now),
|
|
212
|
+
session_budget=p.session_budget_atomic,
|
|
213
|
+
daily_budget=p.daily_budget_atomic,
|
|
214
|
+
per_counterparty_cap=p.per_counterparty_cap_atomic,
|
|
215
|
+
max_per_window=p.max_payments_per_window,
|
|
216
|
+
window_seconds=p.window_seconds,
|
|
217
|
+
replay_window=p.replay_window_seconds)
|
|
218
|
+
if status == "deny":
|
|
219
|
+
return self._deny(str(value), f"aggregate policy limit reached: {value}",
|
|
220
|
+
amount=amount, pay_to=pay_to, network=network, resource_url=resource_url)
|
|
221
|
+
with self._pending_lock:
|
|
222
|
+
self._pending[fingerprint].append(int(value))
|
|
223
|
+
self._audit({"decision": "allow", "amount_atomic": amount, "pay_to": pay_to,
|
|
224
|
+
"network": network, "resource_url": resource_url,
|
|
225
|
+
"intent_hash": self._intent_hash, "reservation_id": int(value)})
|
|
226
|
+
return None
|
|
227
|
+
except StoreError as e:
|
|
228
|
+
return self._deny("INTERNAL_FAIL_CLOSED", f"spend store unavailable; failing closed: {e}",
|
|
229
|
+
resource_url=resource_url)
|
|
230
|
+
except Exception as e: # any unexpected gate error must fail closed, never open
|
|
231
|
+
return self._deny("INTERNAL_FAIL_CLOSED", f"gate evaluation error; failing closed: {e!r}",
|
|
232
|
+
resource_url=resource_url)
|
|
233
|
+
|
|
234
|
+
def _pop_reservation(self, ctx) -> int | None:
|
|
235
|
+
req = ctx.selected_requirements
|
|
236
|
+
payment_required = ctx.payment_required
|
|
237
|
+
resource_url = None
|
|
238
|
+
if getattr(payment_required, "resource", None) is not None:
|
|
239
|
+
resource_url = payment_required.resource.url
|
|
240
|
+
amount = parse_atomic_amount(req.get_amount() if hasattr(req, "get_amount") else None)
|
|
241
|
+
if amount is None:
|
|
242
|
+
return None
|
|
243
|
+
fingerprint = self._fingerprint(
|
|
244
|
+
intent_hash=self._intent_hash, decl_id=self._intent_decl_id,
|
|
245
|
+
network=str(req.network), asset=str(req.asset), pay_to=str(req.pay_to),
|
|
246
|
+
amount=amount, resource_url=resource_url)
|
|
247
|
+
with self._pending_lock:
|
|
248
|
+
stack = self._pending.get(fingerprint)
|
|
249
|
+
if stack:
|
|
250
|
+
rid = stack.pop()
|
|
251
|
+
if not stack:
|
|
252
|
+
del self._pending[fingerprint]
|
|
253
|
+
return rid
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
def after_hook(self, ctx) -> None:
|
|
257
|
+
rid = self._pop_reservation(ctx)
|
|
258
|
+
if rid is not None:
|
|
259
|
+
try:
|
|
260
|
+
self.store.commit(rid)
|
|
261
|
+
except StoreError:
|
|
262
|
+
# Signature exists but ledger failed: keep counting it as
|
|
263
|
+
# reserved (it still counts against budgets). Never release.
|
|
264
|
+
pass
|
|
265
|
+
|
|
266
|
+
def failure_hook(self, ctx) -> None:
|
|
267
|
+
rid = self._pop_reservation(ctx)
|
|
268
|
+
if rid is not None:
|
|
269
|
+
try:
|
|
270
|
+
self.store.release(rid)
|
|
271
|
+
except StoreError:
|
|
272
|
+
pass
|
|
273
|
+
return None
|
policy_gate/policy.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Declarative payment policy for the pre-signing gate (EXP-001).
|
|
2
|
+
|
|
3
|
+
One small JSON document expresses every control (MEMO-003 Q2). All money is
|
|
4
|
+
integer atomic units (e.g. USDC has 6 decimals: $0.10 = 100000). No floats
|
|
5
|
+
anywhere near money. Validation is strict: unknown fields, missing caps,
|
|
6
|
+
non-positive caps and wrong types are all rejected at load time, because a
|
|
7
|
+
policy that fails open is worse than no policy.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
_ATOMIC_RE = re.compile(r"^\d+$")
|
|
16
|
+
|
|
17
|
+
_FIELDS = {
|
|
18
|
+
"per_call_cap_atomic",
|
|
19
|
+
"session_budget_atomic",
|
|
20
|
+
"daily_budget_atomic",
|
|
21
|
+
"per_counterparty_cap_atomic",
|
|
22
|
+
"allowed_networks",
|
|
23
|
+
"allowed_assets",
|
|
24
|
+
"counterparty_allowlist",
|
|
25
|
+
"max_payments_per_window",
|
|
26
|
+
"window_seconds",
|
|
27
|
+
"replay_window_seconds",
|
|
28
|
+
"intent_required",
|
|
29
|
+
"asset_decimals",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PolicyError(ValueError):
|
|
34
|
+
"""Raised when a policy document is invalid. Always fail closed."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _require_int(doc: dict, key: str, *, allow_none: bool = False) -> int | None:
|
|
38
|
+
if key not in doc:
|
|
39
|
+
raise PolicyError(f"policy missing required field: {key}")
|
|
40
|
+
v = doc[key]
|
|
41
|
+
if v is None and allow_none:
|
|
42
|
+
return None
|
|
43
|
+
# bool is a subclass of int; a JSON true is never a money amount.
|
|
44
|
+
if isinstance(v, bool) or not isinstance(v, int):
|
|
45
|
+
raise PolicyError(f"policy field {key} must be an integer (atomic units), got {type(v).__name__}")
|
|
46
|
+
return v
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _require_str_list(doc: dict, key: str) -> list[str]:
|
|
50
|
+
if key not in doc:
|
|
51
|
+
raise PolicyError(f"policy missing required field: {key}")
|
|
52
|
+
v = doc[key]
|
|
53
|
+
if not isinstance(v, list) or not v or not all(isinstance(x, str) and x for x in v):
|
|
54
|
+
raise PolicyError(f"policy field {key} must be a non-empty list of strings")
|
|
55
|
+
return list(v)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class PaymentPolicy:
|
|
60
|
+
per_call_cap_atomic: int
|
|
61
|
+
session_budget_atomic: int
|
|
62
|
+
daily_budget_atomic: int
|
|
63
|
+
allowed_networks: tuple[str, ...]
|
|
64
|
+
allowed_assets: tuple[str, ...] # stored lowercased
|
|
65
|
+
counterparty_allowlist: tuple[str, ...] # stored lowercased; "*" = any payee
|
|
66
|
+
per_counterparty_cap_atomic: int | None = None
|
|
67
|
+
max_payments_per_window: int = 30
|
|
68
|
+
window_seconds: int = 60
|
|
69
|
+
replay_window_seconds: int = 300
|
|
70
|
+
intent_required: bool = True
|
|
71
|
+
asset_decimals: int = 6
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def from_dict(cls, doc: dict) -> "PaymentPolicy":
|
|
75
|
+
if not isinstance(doc, dict):
|
|
76
|
+
raise PolicyError("policy must be a JSON object")
|
|
77
|
+
unknown = set(doc) - _FIELDS
|
|
78
|
+
if unknown:
|
|
79
|
+
raise PolicyError(f"unknown policy fields: {sorted(unknown)}")
|
|
80
|
+
per_call = _require_int(doc, "per_call_cap_atomic")
|
|
81
|
+
session = _require_int(doc, "session_budget_atomic")
|
|
82
|
+
daily = _require_int(doc, "daily_budget_atomic")
|
|
83
|
+
per_cp = _require_int(doc, "per_counterparty_cap_atomic", allow_none=True) \
|
|
84
|
+
if "per_counterparty_cap_atomic" in doc else None
|
|
85
|
+
for name, val in (("per_call_cap_atomic", per_call),
|
|
86
|
+
("session_budget_atomic", session),
|
|
87
|
+
("daily_budget_atomic", daily),
|
|
88
|
+
("per_counterparty_cap_atomic", per_cp)):
|
|
89
|
+
if val is not None and val <= 0:
|
|
90
|
+
raise PolicyError(f"policy field {name} must be > 0")
|
|
91
|
+
# NOTE: no ordering is imposed between caps and budgets. A session
|
|
92
|
+
# budget smaller than the per-call cap is a legitimate configuration
|
|
93
|
+
# (the tighter limit simply binds at runtime); EXP-001 audit pass 1
|
|
94
|
+
# removed an earlier session>=cap / daily>=session rule that rejected
|
|
95
|
+
# such policies.
|
|
96
|
+
mpw = doc.get("max_payments_per_window", 30)
|
|
97
|
+
ws = doc.get("window_seconds", 60)
|
|
98
|
+
rws = doc.get("replay_window_seconds", 300)
|
|
99
|
+
for name, val in (("max_payments_per_window", mpw), ("window_seconds", ws),
|
|
100
|
+
("replay_window_seconds", rws)):
|
|
101
|
+
if isinstance(val, bool) or not isinstance(val, int) or val <= 0:
|
|
102
|
+
raise PolicyError(f"policy field {name} must be a positive integer")
|
|
103
|
+
intent_required = doc.get("intent_required", True)
|
|
104
|
+
if not isinstance(intent_required, bool):
|
|
105
|
+
raise PolicyError("policy field intent_required must be a boolean")
|
|
106
|
+
decimals = doc.get("asset_decimals", 6)
|
|
107
|
+
if isinstance(decimals, bool) or not isinstance(decimals, int) or not (0 <= decimals <= 36):
|
|
108
|
+
raise PolicyError("policy field asset_decimals must be an integer 0..36")
|
|
109
|
+
return cls(
|
|
110
|
+
per_call_cap_atomic=per_call,
|
|
111
|
+
session_budget_atomic=session,
|
|
112
|
+
daily_budget_atomic=daily,
|
|
113
|
+
per_counterparty_cap_atomic=per_cp,
|
|
114
|
+
allowed_networks=tuple(_require_str_list(doc, "allowed_networks")),
|
|
115
|
+
allowed_assets=tuple(a.lower() for a in _require_str_list(doc, "allowed_assets")),
|
|
116
|
+
counterparty_allowlist=tuple(
|
|
117
|
+
a if a == "*" else a.lower()
|
|
118
|
+
for a in _require_str_list(doc, "counterparty_allowlist")),
|
|
119
|
+
max_payments_per_window=mpw,
|
|
120
|
+
window_seconds=ws,
|
|
121
|
+
replay_window_seconds=rws,
|
|
122
|
+
intent_required=intent_required,
|
|
123
|
+
asset_decimals=decimals,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def from_json(cls, text: str) -> "PaymentPolicy":
|
|
128
|
+
try:
|
|
129
|
+
doc = json.loads(text)
|
|
130
|
+
except json.JSONDecodeError as e:
|
|
131
|
+
raise PolicyError(f"policy is not valid JSON: {e}") from e
|
|
132
|
+
return cls.from_dict(doc)
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def from_file(cls, path: str) -> "PaymentPolicy":
|
|
136
|
+
with open(path, encoding="utf-8") as f:
|
|
137
|
+
return cls.from_json(f.read())
|
|
138
|
+
|
|
139
|
+
def network_allowed(self, network: str) -> bool:
|
|
140
|
+
for pat in self.allowed_networks:
|
|
141
|
+
if pat.endswith(":*"):
|
|
142
|
+
if network.startswith(pat[:-1]):
|
|
143
|
+
return True
|
|
144
|
+
elif network == pat:
|
|
145
|
+
return True
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
def asset_allowed(self, asset: str) -> bool:
|
|
149
|
+
return asset.lower() in self.allowed_assets
|
|
150
|
+
|
|
151
|
+
def counterparty_allowed(self, pay_to: str) -> bool:
|
|
152
|
+
return "*" in self.counterparty_allowlist or pay_to.lower() in self.counterparty_allowlist
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def parse_atomic_amount(raw: object) -> int | None:
|
|
156
|
+
"""Parse an x402 atomic amount string. Returns None when malformed.
|
|
157
|
+
|
|
158
|
+
x402 amounts are integer strings in the asset's smallest unit. Anything
|
|
159
|
+
else (decimals, signs, exponents, whitespace tricks, non-strings) is
|
|
160
|
+
malformed and the gate fails closed on it.
|
|
161
|
+
"""
|
|
162
|
+
if not isinstance(raw, str) or not _ATOMIC_RE.fullmatch(raw):
|
|
163
|
+
return None
|
|
164
|
+
return int(raw)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def usd_money_string(atomic: int, decimals: int) -> str:
|
|
168
|
+
"""Render an atomic cap as an x402 Money string like "$1.50" (for SpendControls)."""
|
|
169
|
+
scale = 10 ** decimals
|
|
170
|
+
whole, frac = divmod(atomic, scale)
|
|
171
|
+
if frac == 0:
|
|
172
|
+
return f"${whole}"
|
|
173
|
+
frac_s = str(frac).rjust(decimals, "0").rstrip("0")
|
|
174
|
+
return f"${whole}.{frac_s}"
|