pyagentgate 0.1.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.
- agentgate/__init__.py +53 -0
- agentgate/checks.py +257 -0
- agentgate/decorators.py +129 -0
- agentgate/gate.py +131 -0
- agentgate/ledger.py +74 -0
- agentgate/loader.py +101 -0
- agentgate/models.py +179 -0
- pyagentgate-0.1.0.dist-info/METADATA +171 -0
- pyagentgate-0.1.0.dist-info/RECORD +11 -0
- pyagentgate-0.1.0.dist-info/WHEEL +4 -0
- pyagentgate-0.1.0.dist-info/licenses/LICENSE +21 -0
agentgate/__init__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""AgentGate — Open-source policy engine for agentic payments."""
|
|
2
|
+
|
|
3
|
+
from .decorators import GatedTransactionDenied, GatedTransactionEscalated, gated
|
|
4
|
+
from .gate import EscalationHandler, Gate, LoggingEscalationHandler
|
|
5
|
+
from .ledger import InMemoryLedger, LedgerBackend
|
|
6
|
+
from .loader import load_policy
|
|
7
|
+
from .models import (
|
|
8
|
+
AuditEntry,
|
|
9
|
+
Check,
|
|
10
|
+
CheckResult,
|
|
11
|
+
Decision,
|
|
12
|
+
EscalationRules,
|
|
13
|
+
EvaluationResult,
|
|
14
|
+
MerchantRules,
|
|
15
|
+
Policy,
|
|
16
|
+
PolicyLimits,
|
|
17
|
+
TimeRestrictions,
|
|
18
|
+
Transaction,
|
|
19
|
+
VelocityRules,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
# Core
|
|
26
|
+
"Gate",
|
|
27
|
+
"Policy",
|
|
28
|
+
"Transaction",
|
|
29
|
+
"Decision",
|
|
30
|
+
"EvaluationResult",
|
|
31
|
+
# Policy components
|
|
32
|
+
"PolicyLimits",
|
|
33
|
+
"MerchantRules",
|
|
34
|
+
"VelocityRules",
|
|
35
|
+
"EscalationRules",
|
|
36
|
+
"TimeRestrictions",
|
|
37
|
+
# Results
|
|
38
|
+
"Check",
|
|
39
|
+
"CheckResult",
|
|
40
|
+
"AuditEntry",
|
|
41
|
+
# Escalation
|
|
42
|
+
"EscalationHandler",
|
|
43
|
+
"LoggingEscalationHandler",
|
|
44
|
+
# Ledger
|
|
45
|
+
"InMemoryLedger",
|
|
46
|
+
"LedgerBackend",
|
|
47
|
+
# Loader
|
|
48
|
+
"load_policy",
|
|
49
|
+
# Decorator
|
|
50
|
+
"gated",
|
|
51
|
+
"GatedTransactionDenied",
|
|
52
|
+
"GatedTransactionEscalated",
|
|
53
|
+
]
|
agentgate/checks.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Individual policy checks. Each is a pure function: transaction + policy + ledger → Check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
|
|
8
|
+
from .ledger import InMemoryLedger
|
|
9
|
+
from .models import Check, CheckResult, Policy, Transaction
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def check_amount_limit(tx: Transaction, policy: Policy, **_) -> Check:
|
|
13
|
+
"""Check if transaction amount exceeds per-transaction limit."""
|
|
14
|
+
limit = policy.limits.per_transaction
|
|
15
|
+
if limit is None:
|
|
16
|
+
return Check("amount_limit", CheckResult.SKIP, "No per-transaction limit set")
|
|
17
|
+
if tx.amount > limit:
|
|
18
|
+
return Check(
|
|
19
|
+
"amount_limit",
|
|
20
|
+
CheckResult.FAIL,
|
|
21
|
+
f"Amount {tx.amount} exceeds limit {limit}",
|
|
22
|
+
{"amount": tx.amount, "limit": limit},
|
|
23
|
+
)
|
|
24
|
+
return Check("amount_limit", CheckResult.PASS, f"Amount {tx.amount} within limit {limit}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def check_daily_limit(tx: Transaction, policy: Policy, ledger: InMemoryLedger, **_) -> Check:
|
|
28
|
+
"""Check if transaction would exceed daily spending limit."""
|
|
29
|
+
limit = policy.limits.daily
|
|
30
|
+
if limit is None:
|
|
31
|
+
return Check("daily_limit", CheckResult.SKIP, "No daily limit set")
|
|
32
|
+
now = datetime.now(timezone.utc)
|
|
33
|
+
start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
34
|
+
spent = ledger.total_since(tx.agent_id, start_of_day)
|
|
35
|
+
if spent + tx.amount > limit:
|
|
36
|
+
return Check(
|
|
37
|
+
"daily_limit",
|
|
38
|
+
CheckResult.FAIL,
|
|
39
|
+
f"Daily total {spent + tx.amount:.2f} would exceed limit {limit}",
|
|
40
|
+
{"spent_today": spent, "proposed": tx.amount, "limit": limit},
|
|
41
|
+
)
|
|
42
|
+
return Check("daily_limit", CheckResult.PASS, f"Daily spend OK ({spent + tx.amount:.2f}/{limit})")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def check_weekly_limit(tx: Transaction, policy: Policy, ledger: InMemoryLedger, **_) -> Check:
|
|
46
|
+
"""Check if transaction would exceed weekly spending limit."""
|
|
47
|
+
limit = policy.limits.weekly
|
|
48
|
+
if limit is None:
|
|
49
|
+
return Check("weekly_limit", CheckResult.SKIP, "No weekly limit set")
|
|
50
|
+
now = datetime.now(timezone.utc)
|
|
51
|
+
week_start = now - timedelta(days=now.weekday())
|
|
52
|
+
week_start = week_start.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
53
|
+
spent = ledger.total_since(tx.agent_id, week_start)
|
|
54
|
+
if spent + tx.amount > limit:
|
|
55
|
+
return Check(
|
|
56
|
+
"weekly_limit",
|
|
57
|
+
CheckResult.FAIL,
|
|
58
|
+
f"Weekly total {spent + tx.amount:.2f} would exceed limit {limit}",
|
|
59
|
+
{"spent_this_week": spent, "proposed": tx.amount, "limit": limit},
|
|
60
|
+
)
|
|
61
|
+
return Check("weekly_limit", CheckResult.PASS, f"Weekly spend OK ({spent + tx.amount:.2f}/{limit})")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def check_monthly_limit(tx: Transaction, policy: Policy, ledger: InMemoryLedger, **_) -> Check:
|
|
65
|
+
"""Check if transaction would exceed monthly spending limit."""
|
|
66
|
+
limit = policy.limits.monthly
|
|
67
|
+
if limit is None:
|
|
68
|
+
return Check("monthly_limit", CheckResult.SKIP, "No monthly limit set")
|
|
69
|
+
now = datetime.now(timezone.utc)
|
|
70
|
+
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
|
71
|
+
spent = ledger.total_since(tx.agent_id, month_start)
|
|
72
|
+
if spent + tx.amount > limit:
|
|
73
|
+
return Check(
|
|
74
|
+
"monthly_limit",
|
|
75
|
+
CheckResult.FAIL,
|
|
76
|
+
f"Monthly total {spent + tx.amount:.2f} would exceed limit {limit}",
|
|
77
|
+
{"spent_this_month": spent, "proposed": tx.amount, "limit": limit},
|
|
78
|
+
)
|
|
79
|
+
return Check("monthly_limit", CheckResult.PASS, f"Monthly spend OK ({spent + tx.amount:.2f}/{limit})")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def check_category(tx: Transaction, policy: Policy, **_) -> Check:
|
|
83
|
+
"""Check if transaction category is allowed."""
|
|
84
|
+
rules = policy.merchants
|
|
85
|
+
|
|
86
|
+
# Check blocked categories first
|
|
87
|
+
if tx.category in rules.blocked_categories:
|
|
88
|
+
return Check(
|
|
89
|
+
"category",
|
|
90
|
+
CheckResult.FAIL,
|
|
91
|
+
f"Category '{tx.category}' is blocked",
|
|
92
|
+
{"category": tx.category, "blocked": rules.blocked_categories},
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# If allowlist is set, category must be in it
|
|
96
|
+
if rules.allowed_categories is not None and tx.category not in rules.allowed_categories:
|
|
97
|
+
return Check(
|
|
98
|
+
"category",
|
|
99
|
+
CheckResult.FAIL,
|
|
100
|
+
f"Category '{tx.category}' not in allowed list",
|
|
101
|
+
{"category": tx.category, "allowed": rules.allowed_categories},
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
return Check("category", CheckResult.PASS, f"Category '{tx.category}' is allowed")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def check_merchant(tx: Transaction, policy: Policy, **_) -> Check:
|
|
108
|
+
"""Check if merchant is allowed."""
|
|
109
|
+
rules = policy.merchants
|
|
110
|
+
|
|
111
|
+
# Check blocked merchants (supports glob patterns)
|
|
112
|
+
for pattern in rules.blocked_merchants:
|
|
113
|
+
if fnmatch.fnmatch(tx.merchant.lower(), pattern.lower()):
|
|
114
|
+
return Check(
|
|
115
|
+
"merchant",
|
|
116
|
+
CheckResult.FAIL,
|
|
117
|
+
f"Merchant '{tx.merchant}' matches blocked pattern '{pattern}'",
|
|
118
|
+
{"merchant": tx.merchant, "pattern": pattern},
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# If allowlist is set, merchant must be in it
|
|
122
|
+
if rules.allowed_merchants is not None:
|
|
123
|
+
matched = any(
|
|
124
|
+
fnmatch.fnmatch(tx.merchant.lower(), p.lower())
|
|
125
|
+
for p in rules.allowed_merchants
|
|
126
|
+
)
|
|
127
|
+
if not matched:
|
|
128
|
+
return Check(
|
|
129
|
+
"merchant",
|
|
130
|
+
CheckResult.FAIL,
|
|
131
|
+
f"Merchant '{tx.merchant}' not in allowed list",
|
|
132
|
+
{"merchant": tx.merchant, "allowed": rules.allowed_merchants},
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
return Check("merchant", CheckResult.PASS, f"Merchant '{tx.merchant}' is allowed")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def check_velocity(tx: Transaction, policy: Policy, ledger: InMemoryLedger, **_) -> Check:
|
|
139
|
+
"""Check transaction velocity (rate limiting)."""
|
|
140
|
+
rules = policy.velocity
|
|
141
|
+
now = datetime.now(timezone.utc)
|
|
142
|
+
failures = []
|
|
143
|
+
|
|
144
|
+
# Cooldown check
|
|
145
|
+
if rules.cooldown_seconds > 0:
|
|
146
|
+
last_time = ledger.last_transaction_time(tx.agent_id)
|
|
147
|
+
if last_time:
|
|
148
|
+
elapsed = (now - last_time).total_seconds()
|
|
149
|
+
if elapsed < rules.cooldown_seconds:
|
|
150
|
+
failures.append(
|
|
151
|
+
f"Cooldown: {elapsed:.1f}s since last tx, need {rules.cooldown_seconds}s"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Hourly rate check
|
|
155
|
+
if rules.max_per_hour is not None:
|
|
156
|
+
hour_ago = now - timedelta(hours=1)
|
|
157
|
+
count = ledger.count_since(tx.agent_id, hour_ago)
|
|
158
|
+
if count >= rules.max_per_hour:
|
|
159
|
+
failures.append(f"Hourly limit: {count} txns in last hour (max {rules.max_per_hour})")
|
|
160
|
+
|
|
161
|
+
# Daily rate check
|
|
162
|
+
if rules.max_per_day is not None:
|
|
163
|
+
start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
164
|
+
count = ledger.count_since(tx.agent_id, start_of_day)
|
|
165
|
+
if count >= rules.max_per_day:
|
|
166
|
+
failures.append(f"Daily limit: {count} txns today (max {rules.max_per_day})")
|
|
167
|
+
|
|
168
|
+
if failures:
|
|
169
|
+
return Check(
|
|
170
|
+
"velocity",
|
|
171
|
+
CheckResult.FAIL,
|
|
172
|
+
"; ".join(failures),
|
|
173
|
+
{"failures": failures},
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
return Check("velocity", CheckResult.PASS, "Velocity checks passed")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def check_time_restrictions(tx: Transaction, policy: Policy, **_) -> Check:
|
|
180
|
+
"""Check if current time is within allowed transaction window."""
|
|
181
|
+
rules = policy.time_restrictions
|
|
182
|
+
now = datetime.now(timezone.utc)
|
|
183
|
+
|
|
184
|
+
if rules.allowed_days is not None:
|
|
185
|
+
if now.weekday() not in rules.allowed_days:
|
|
186
|
+
return Check(
|
|
187
|
+
"time_restriction",
|
|
188
|
+
CheckResult.FAIL,
|
|
189
|
+
f"Day {now.strftime('%A')} not in allowed days",
|
|
190
|
+
{"day": now.weekday(), "allowed": rules.allowed_days},
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
if rules.allowed_hours is not None:
|
|
194
|
+
start, end = rules.allowed_hours
|
|
195
|
+
if not (start <= now.hour < end):
|
|
196
|
+
return Check(
|
|
197
|
+
"time_restriction",
|
|
198
|
+
CheckResult.FAIL,
|
|
199
|
+
f"Hour {now.hour} outside allowed range {start}-{end}",
|
|
200
|
+
{"hour": now.hour, "allowed_start": start, "allowed_end": end},
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
return Check("time_restriction", CheckResult.PASS, "Within allowed time window")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def check_escalation(tx: Transaction, policy: Policy, ledger: InMemoryLedger, **_) -> Check:
|
|
207
|
+
"""Check if transaction should be escalated to a human.
|
|
208
|
+
|
|
209
|
+
Unlike other checks, FAIL here means ESCALATE, not DENY.
|
|
210
|
+
"""
|
|
211
|
+
rules = policy.escalation
|
|
212
|
+
reasons = []
|
|
213
|
+
|
|
214
|
+
if rules.above_amount is not None and tx.amount > rules.above_amount:
|
|
215
|
+
reasons.append(f"Amount {tx.amount} exceeds escalation threshold {rules.above_amount}")
|
|
216
|
+
|
|
217
|
+
if rules.on_new_merchant:
|
|
218
|
+
known = ledger.get_known_merchants(tx.agent_id)
|
|
219
|
+
if tx.merchant not in known and known: # don't escalate the very first tx
|
|
220
|
+
reasons.append(f"New merchant: {tx.merchant}")
|
|
221
|
+
|
|
222
|
+
if rules.on_new_category:
|
|
223
|
+
known = ledger.get_known_categories(tx.agent_id)
|
|
224
|
+
if tx.category not in known and known:
|
|
225
|
+
reasons.append(f"New category: {tx.category}")
|
|
226
|
+
|
|
227
|
+
if rules.on_cumulative_above is not None:
|
|
228
|
+
now = datetime.now(timezone.utc)
|
|
229
|
+
start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
230
|
+
total = ledger.total_since(tx.agent_id, start_of_day) + tx.amount
|
|
231
|
+
if total > rules.on_cumulative_above:
|
|
232
|
+
reasons.append(f"Cumulative total {total:.2f} exceeds escalation threshold {rules.on_cumulative_above}")
|
|
233
|
+
|
|
234
|
+
if reasons:
|
|
235
|
+
return Check(
|
|
236
|
+
"escalation",
|
|
237
|
+
CheckResult.FAIL,
|
|
238
|
+
"; ".join(reasons),
|
|
239
|
+
{"reasons": reasons},
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
return Check("escalation", CheckResult.PASS, "No escalation needed")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# Registry of all checks in evaluation order.
|
|
246
|
+
# Escalation is last because it only matters if everything else passes.
|
|
247
|
+
ALL_CHECKS = [
|
|
248
|
+
check_amount_limit,
|
|
249
|
+
check_daily_limit,
|
|
250
|
+
check_weekly_limit,
|
|
251
|
+
check_monthly_limit,
|
|
252
|
+
check_category,
|
|
253
|
+
check_merchant,
|
|
254
|
+
check_velocity,
|
|
255
|
+
check_time_restrictions,
|
|
256
|
+
check_escalation,
|
|
257
|
+
]
|
agentgate/decorators.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Decorators for wrapping payment functions with gate enforcement."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import inspect
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
from .gate import Gate
|
|
10
|
+
from .models import Decision, Policy, Transaction
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GatedTransactionDenied(Exception):
|
|
14
|
+
"""Raised when a gated transaction is denied."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, result):
|
|
17
|
+
self.result = result
|
|
18
|
+
reasons = "; ".join(c.message for c in result.failed_checks)
|
|
19
|
+
super().__init__(f"Transaction denied: {reasons}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class GatedTransactionEscalated(Exception):
|
|
23
|
+
"""Raised when a gated transaction requires escalation."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, result):
|
|
26
|
+
self.result = result
|
|
27
|
+
super().__init__(f"Transaction requires escalation: {', '.join(result.escalation_reasons)}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def gated(
|
|
31
|
+
policy: Policy | None = None,
|
|
32
|
+
gate: Gate | None = None,
|
|
33
|
+
amount_param: str = "amount",
|
|
34
|
+
merchant_param: str = "merchant",
|
|
35
|
+
category_param: str = "category",
|
|
36
|
+
agent_id: str = "default-agent",
|
|
37
|
+
reasoning_param: str | None = None,
|
|
38
|
+
):
|
|
39
|
+
"""Decorator that gates a payment function behind policy evaluation.
|
|
40
|
+
|
|
41
|
+
Usage:
|
|
42
|
+
@gated(policy=my_policy)
|
|
43
|
+
async def buy_item(merchant, amount, item):
|
|
44
|
+
return await stripe.checkout(...)
|
|
45
|
+
|
|
46
|
+
# Or with an existing gate:
|
|
47
|
+
@gated(gate=my_gate, amount_param="price")
|
|
48
|
+
def purchase(store, price):
|
|
49
|
+
...
|
|
50
|
+
|
|
51
|
+
The decorated function will raise GatedTransactionDenied if the
|
|
52
|
+
transaction is denied, or GatedTransactionEscalated if it needs
|
|
53
|
+
human approval.
|
|
54
|
+
"""
|
|
55
|
+
if policy is None and gate is None:
|
|
56
|
+
raise ValueError("Either policy or gate must be provided")
|
|
57
|
+
|
|
58
|
+
_gate = gate or Gate(policy=policy)
|
|
59
|
+
|
|
60
|
+
def decorator(func: Callable) -> Callable:
|
|
61
|
+
@functools.wraps(func)
|
|
62
|
+
def wrapper(*args, **kwargs):
|
|
63
|
+
# Extract transaction params from the function call
|
|
64
|
+
sig = inspect.signature(func)
|
|
65
|
+
bound = sig.bind(*args, **kwargs)
|
|
66
|
+
bound.apply_defaults()
|
|
67
|
+
|
|
68
|
+
amount = bound.arguments.get(amount_param, 0)
|
|
69
|
+
merchant = bound.arguments.get(merchant_param, "unknown")
|
|
70
|
+
category = bound.arguments.get(category_param, "general")
|
|
71
|
+
reasoning = ""
|
|
72
|
+
if reasoning_param:
|
|
73
|
+
reasoning = bound.arguments.get(reasoning_param, "")
|
|
74
|
+
|
|
75
|
+
tx = Transaction(
|
|
76
|
+
amount=float(amount),
|
|
77
|
+
currency="USD",
|
|
78
|
+
merchant=str(merchant),
|
|
79
|
+
category=str(category),
|
|
80
|
+
agent_id=agent_id,
|
|
81
|
+
reasoning=reasoning,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
result = _gate.evaluate(tx)
|
|
85
|
+
|
|
86
|
+
if result.decision == Decision.DENY:
|
|
87
|
+
raise GatedTransactionDenied(result)
|
|
88
|
+
elif result.decision == Decision.ESCALATE:
|
|
89
|
+
raise GatedTransactionEscalated(result)
|
|
90
|
+
|
|
91
|
+
return func(*args, **kwargs)
|
|
92
|
+
|
|
93
|
+
# Async variant
|
|
94
|
+
@functools.wraps(func)
|
|
95
|
+
async def async_wrapper(*args, **kwargs):
|
|
96
|
+
sig = inspect.signature(func)
|
|
97
|
+
bound = sig.bind(*args, **kwargs)
|
|
98
|
+
bound.apply_defaults()
|
|
99
|
+
|
|
100
|
+
amount = bound.arguments.get(amount_param, 0)
|
|
101
|
+
merchant = bound.arguments.get(merchant_param, "unknown")
|
|
102
|
+
category = bound.arguments.get(category_param, "general")
|
|
103
|
+
reasoning = ""
|
|
104
|
+
if reasoning_param:
|
|
105
|
+
reasoning = bound.arguments.get(reasoning_param, "")
|
|
106
|
+
|
|
107
|
+
tx = Transaction(
|
|
108
|
+
amount=float(amount),
|
|
109
|
+
currency="USD",
|
|
110
|
+
merchant=str(merchant),
|
|
111
|
+
category=str(category),
|
|
112
|
+
agent_id=agent_id,
|
|
113
|
+
reasoning=reasoning,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
result = _gate.evaluate(tx)
|
|
117
|
+
|
|
118
|
+
if result.decision == Decision.DENY:
|
|
119
|
+
raise GatedTransactionDenied(result)
|
|
120
|
+
elif result.decision == Decision.ESCALATE:
|
|
121
|
+
raise GatedTransactionEscalated(result)
|
|
122
|
+
|
|
123
|
+
return await func(*args, **kwargs)
|
|
124
|
+
|
|
125
|
+
if inspect.iscoroutinefunction(func):
|
|
126
|
+
return async_wrapper
|
|
127
|
+
return wrapper
|
|
128
|
+
|
|
129
|
+
return decorator
|
agentgate/gate.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""The Gate — core evaluation engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Callable, Protocol
|
|
7
|
+
|
|
8
|
+
from .checks import ALL_CHECKS
|
|
9
|
+
from .ledger import InMemoryLedger
|
|
10
|
+
from .models import (
|
|
11
|
+
AuditEntry,
|
|
12
|
+
Check,
|
|
13
|
+
CheckResult,
|
|
14
|
+
Decision,
|
|
15
|
+
EvaluationResult,
|
|
16
|
+
Policy,
|
|
17
|
+
Transaction,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class EscalationHandler(Protocol):
|
|
22
|
+
"""Protocol for handling escalated transactions."""
|
|
23
|
+
|
|
24
|
+
async def escalate(
|
|
25
|
+
self, transaction: Transaction, reasons: list[str]
|
|
26
|
+
) -> bool:
|
|
27
|
+
"""Return True to approve, False to deny."""
|
|
28
|
+
...
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class LoggingEscalationHandler:
|
|
32
|
+
"""Default escalation handler — logs and denies."""
|
|
33
|
+
|
|
34
|
+
async def escalate(
|
|
35
|
+
self, transaction: Transaction, reasons: list[str]
|
|
36
|
+
) -> bool:
|
|
37
|
+
print(f"[ESCALATION] tx={transaction.id} reasons={reasons}")
|
|
38
|
+
return False # deny by default — safe fallback
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Gate:
|
|
42
|
+
"""Main policy evaluation engine.
|
|
43
|
+
|
|
44
|
+
Usage:
|
|
45
|
+
gate = Gate(policy=my_policy)
|
|
46
|
+
result = gate.evaluate(transaction)
|
|
47
|
+
if result.passed:
|
|
48
|
+
# proceed with payment
|
|
49
|
+
elif result.decision == Decision.ESCALATE:
|
|
50
|
+
# human review needed
|
|
51
|
+
else:
|
|
52
|
+
# denied — result.failed_checks has details
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
policy: Policy,
|
|
58
|
+
ledger: InMemoryLedger | None = None,
|
|
59
|
+
escalation_handler: EscalationHandler | None = None,
|
|
60
|
+
checks: list[Callable] | None = None,
|
|
61
|
+
audit_log: list[AuditEntry] | None = None,
|
|
62
|
+
):
|
|
63
|
+
self.policy = policy
|
|
64
|
+
self.ledger = ledger or InMemoryLedger()
|
|
65
|
+
self.escalation_handler = escalation_handler
|
|
66
|
+
self._checks = checks or ALL_CHECKS
|
|
67
|
+
self._audit_log: list[AuditEntry] = audit_log if audit_log is not None else []
|
|
68
|
+
|
|
69
|
+
def evaluate(self, tx: Transaction) -> EvaluationResult:
|
|
70
|
+
"""Evaluate a transaction against the policy. Synchronous."""
|
|
71
|
+
results: list[Check] = []
|
|
72
|
+
escalation_reasons: list[str] = []
|
|
73
|
+
|
|
74
|
+
for check_fn in self._checks:
|
|
75
|
+
check = check_fn(tx=tx, policy=self.policy, ledger=self.ledger)
|
|
76
|
+
results.append(check)
|
|
77
|
+
|
|
78
|
+
# Separate hard failures from escalation
|
|
79
|
+
hard_fails = [
|
|
80
|
+
c for c in results
|
|
81
|
+
if c.result == CheckResult.FAIL and c.name != "escalation"
|
|
82
|
+
]
|
|
83
|
+
escalation_check = next(
|
|
84
|
+
(c for c in results if c.name == "escalation"), None
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Determine decision
|
|
88
|
+
if hard_fails:
|
|
89
|
+
decision = Decision.DENY
|
|
90
|
+
elif escalation_check and escalation_check.result == CheckResult.FAIL:
|
|
91
|
+
decision = Decision.ESCALATE
|
|
92
|
+
escalation_reasons = escalation_check.details.get("reasons", [])
|
|
93
|
+
else:
|
|
94
|
+
decision = Decision.ALLOW
|
|
95
|
+
|
|
96
|
+
# Build audit entry
|
|
97
|
+
audit = AuditEntry(
|
|
98
|
+
timestamp=datetime.now(timezone.utc),
|
|
99
|
+
transaction_id=tx.id,
|
|
100
|
+
agent_id=tx.agent_id,
|
|
101
|
+
decision=decision,
|
|
102
|
+
checks_passed=[c.name for c in results if c.result == CheckResult.PASS],
|
|
103
|
+
checks_failed=[c.name for c in results if c.result == CheckResult.FAIL],
|
|
104
|
+
escalation_reasons=escalation_reasons,
|
|
105
|
+
reasoning=tx.reasoning,
|
|
106
|
+
policy_name=self.policy.name,
|
|
107
|
+
policy_version=self.policy.version,
|
|
108
|
+
)
|
|
109
|
+
self._audit_log.append(audit)
|
|
110
|
+
|
|
111
|
+
# Record in ledger if allowed
|
|
112
|
+
if decision == Decision.ALLOW:
|
|
113
|
+
self.ledger.record(tx.agent_id, tx)
|
|
114
|
+
|
|
115
|
+
result = EvaluationResult(
|
|
116
|
+
decision=decision,
|
|
117
|
+
transaction=tx,
|
|
118
|
+
checks=results,
|
|
119
|
+
escalation_reasons=escalation_reasons,
|
|
120
|
+
audit=audit,
|
|
121
|
+
)
|
|
122
|
+
return result
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def audit_log(self) -> list[AuditEntry]:
|
|
126
|
+
return list(self._audit_log)
|
|
127
|
+
|
|
128
|
+
def reset(self) -> None:
|
|
129
|
+
"""Clear ledger and audit log. Useful for testing."""
|
|
130
|
+
self.ledger.clear()
|
|
131
|
+
self._audit_log.clear()
|
agentgate/ledger.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Transaction ledger for tracking spending history.
|
|
2
|
+
|
|
3
|
+
The ledger is what makes velocity checks, daily/weekly/monthly limits,
|
|
4
|
+
and "new merchant" detection possible. It's an in-memory store by default,
|
|
5
|
+
with a protocol for plugging in persistent backends.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections import defaultdict
|
|
11
|
+
from datetime import datetime, timedelta, timezone
|
|
12
|
+
from typing import Protocol
|
|
13
|
+
|
|
14
|
+
from .models import Transaction
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LedgerBackend(Protocol):
|
|
18
|
+
"""Protocol for persistent ledger backends."""
|
|
19
|
+
|
|
20
|
+
def record(self, agent_id: str, transaction: Transaction) -> None: ...
|
|
21
|
+
def get_transactions(
|
|
22
|
+
self, agent_id: str, since: datetime
|
|
23
|
+
) -> list[Transaction]: ...
|
|
24
|
+
def get_known_merchants(self, agent_id: str) -> set[str]: ...
|
|
25
|
+
def get_known_categories(self, agent_id: str) -> set[str]: ...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InMemoryLedger:
|
|
29
|
+
"""In-memory transaction ledger. Good for dev/testing, not production."""
|
|
30
|
+
|
|
31
|
+
def __init__(self):
|
|
32
|
+
self._transactions: dict[str, list[Transaction]] = defaultdict(list)
|
|
33
|
+
self._known_merchants: dict[str, set[str]] = defaultdict(set)
|
|
34
|
+
self._known_categories: dict[str, set[str]] = defaultdict(set)
|
|
35
|
+
|
|
36
|
+
def record(self, agent_id: str, transaction: Transaction) -> None:
|
|
37
|
+
self._transactions[agent_id].append(transaction)
|
|
38
|
+
self._known_merchants[agent_id].add(transaction.merchant)
|
|
39
|
+
self._known_categories[agent_id].add(transaction.category)
|
|
40
|
+
|
|
41
|
+
def get_transactions(
|
|
42
|
+
self, agent_id: str, since: datetime
|
|
43
|
+
) -> list[Transaction]:
|
|
44
|
+
return [
|
|
45
|
+
tx
|
|
46
|
+
for tx in self._transactions.get(agent_id, [])
|
|
47
|
+
if tx.timestamp >= since
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
def get_known_merchants(self, agent_id: str) -> set[str]:
|
|
51
|
+
return self._known_merchants.get(agent_id, set())
|
|
52
|
+
|
|
53
|
+
def get_known_categories(self, agent_id: str) -> set[str]:
|
|
54
|
+
return self._known_categories.get(agent_id, set())
|
|
55
|
+
|
|
56
|
+
def total_since(self, agent_id: str, since: datetime) -> float:
|
|
57
|
+
return sum(tx.amount for tx in self.get_transactions(agent_id, since))
|
|
58
|
+
|
|
59
|
+
def count_since(self, agent_id: str, since: datetime) -> int:
|
|
60
|
+
return len(self.get_transactions(agent_id, since))
|
|
61
|
+
|
|
62
|
+
def last_transaction_time(self, agent_id: str) -> datetime | None:
|
|
63
|
+
txs = self._transactions.get(agent_id, [])
|
|
64
|
+
return txs[-1].timestamp if txs else None
|
|
65
|
+
|
|
66
|
+
def clear(self, agent_id: str | None = None) -> None:
|
|
67
|
+
if agent_id:
|
|
68
|
+
self._transactions.pop(agent_id, None)
|
|
69
|
+
self._known_merchants.pop(agent_id, None)
|
|
70
|
+
self._known_categories.pop(agent_id, None)
|
|
71
|
+
else:
|
|
72
|
+
self._transactions.clear()
|
|
73
|
+
self._known_merchants.clear()
|
|
74
|
+
self._known_categories.clear()
|
agentgate/loader.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Load policies from YAML files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .models import (
|
|
9
|
+
EscalationRules,
|
|
10
|
+
MerchantRules,
|
|
11
|
+
Policy,
|
|
12
|
+
PolicyLimits,
|
|
13
|
+
TimeRestrictions,
|
|
14
|
+
VelocityRules,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import yaml
|
|
19
|
+
|
|
20
|
+
HAS_YAML = True
|
|
21
|
+
except ImportError:
|
|
22
|
+
HAS_YAML = False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_policy(source: str | Path | dict) -> Policy:
|
|
26
|
+
"""Load a policy from a YAML file path, YAML string, or dict.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
source: File path, YAML string, or already-parsed dict.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
A Policy instance.
|
|
33
|
+
"""
|
|
34
|
+
if isinstance(source, dict):
|
|
35
|
+
data = source
|
|
36
|
+
elif isinstance(source, Path) or (
|
|
37
|
+
isinstance(source, str) and not source.strip().startswith("{")
|
|
38
|
+
and "\n" not in source
|
|
39
|
+
and Path(source).suffix in (".yaml", ".yml")
|
|
40
|
+
):
|
|
41
|
+
if not HAS_YAML:
|
|
42
|
+
raise ImportError("PyYAML required: pip install pyyaml")
|
|
43
|
+
with open(source) as f:
|
|
44
|
+
data = yaml.safe_load(f)
|
|
45
|
+
else:
|
|
46
|
+
if not HAS_YAML:
|
|
47
|
+
raise ImportError("PyYAML required: pip install pyyaml")
|
|
48
|
+
data = yaml.safe_load(source)
|
|
49
|
+
|
|
50
|
+
return _dict_to_policy(data)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _dict_to_policy(data: dict[str, Any]) -> Policy:
|
|
54
|
+
"""Convert a raw dict to a Policy."""
|
|
55
|
+
limits_data = data.get("limits", {})
|
|
56
|
+
limits = PolicyLimits(
|
|
57
|
+
per_transaction=limits_data.get("per_transaction"),
|
|
58
|
+
daily=limits_data.get("daily"),
|
|
59
|
+
weekly=limits_data.get("weekly"),
|
|
60
|
+
monthly=limits_data.get("monthly"),
|
|
61
|
+
currency=limits_data.get("currency", "USD"),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
merch_data = data.get("merchants", {})
|
|
65
|
+
merchants = MerchantRules(
|
|
66
|
+
allowed_categories=merch_data.get("allowed_categories"),
|
|
67
|
+
blocked_categories=merch_data.get("blocked_categories", []),
|
|
68
|
+
allowed_merchants=merch_data.get("allowed_merchants"),
|
|
69
|
+
blocked_merchants=merch_data.get("blocked_merchants", []),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
vel_data = data.get("velocity", {})
|
|
73
|
+
velocity = VelocityRules(
|
|
74
|
+
max_per_hour=vel_data.get("max_transactions_per_hour"),
|
|
75
|
+
max_per_day=vel_data.get("max_transactions_per_day"),
|
|
76
|
+
cooldown_seconds=vel_data.get("cooldown_seconds", 0),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
esc_data = data.get("escalation", {})
|
|
80
|
+
escalation = EscalationRules(
|
|
81
|
+
above_amount=esc_data.get("above_amount"),
|
|
82
|
+
on_new_merchant=esc_data.get("on_new_merchant", False),
|
|
83
|
+
on_new_category=esc_data.get("on_new_category", False),
|
|
84
|
+
on_cumulative_above=esc_data.get("on_cumulative_above"),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
time_data = data.get("time_restrictions", {})
|
|
88
|
+
time_rules = TimeRestrictions(
|
|
89
|
+
allowed_hours=tuple(time_data["allowed_hours"]) if "allowed_hours" in time_data else None,
|
|
90
|
+
allowed_days=time_data.get("allowed_days"),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
return Policy(
|
|
94
|
+
name=data.get("name", "default"),
|
|
95
|
+
version=str(data.get("version", "1")),
|
|
96
|
+
limits=limits,
|
|
97
|
+
merchants=merchants,
|
|
98
|
+
velocity=velocity,
|
|
99
|
+
escalation=escalation,
|
|
100
|
+
time_restrictions=time_rules,
|
|
101
|
+
)
|
agentgate/models.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Core data models for AgentGate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Decision(str, Enum):
|
|
13
|
+
"""Result of a policy evaluation."""
|
|
14
|
+
|
|
15
|
+
ALLOW = "allow"
|
|
16
|
+
DENY = "deny"
|
|
17
|
+
ESCALATE = "escalate"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CheckResult(str, Enum):
|
|
21
|
+
"""Result of an individual policy check."""
|
|
22
|
+
|
|
23
|
+
PASS = "pass"
|
|
24
|
+
FAIL = "fail"
|
|
25
|
+
SKIP = "skip" # check not applicable
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Transaction:
|
|
30
|
+
"""A proposed agent-initiated transaction."""
|
|
31
|
+
|
|
32
|
+
amount: float
|
|
33
|
+
currency: str
|
|
34
|
+
merchant: str
|
|
35
|
+
category: str
|
|
36
|
+
agent_id: str
|
|
37
|
+
reasoning: str = ""
|
|
38
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
39
|
+
id: str = field(default_factory=lambda: f"tx_{uuid.uuid4().hex[:12]}")
|
|
40
|
+
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
41
|
+
|
|
42
|
+
def __post_init__(self):
|
|
43
|
+
if self.amount < 0:
|
|
44
|
+
raise ValueError("Transaction amount cannot be negative")
|
|
45
|
+
if not self.currency:
|
|
46
|
+
raise ValueError("Currency is required")
|
|
47
|
+
if not self.merchant:
|
|
48
|
+
raise ValueError("Merchant is required")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class PolicyLimits:
|
|
53
|
+
"""Spending limits."""
|
|
54
|
+
|
|
55
|
+
per_transaction: float | None = None
|
|
56
|
+
daily: float | None = None
|
|
57
|
+
weekly: float | None = None
|
|
58
|
+
monthly: float | None = None
|
|
59
|
+
currency: str = "USD"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class MerchantRules:
|
|
64
|
+
"""Merchant/category allow/block rules."""
|
|
65
|
+
|
|
66
|
+
allowed_categories: list[str] | None = None # None = all allowed
|
|
67
|
+
blocked_categories: list[str] = field(default_factory=list)
|
|
68
|
+
allowed_merchants: list[str] | None = None # None = all allowed
|
|
69
|
+
blocked_merchants: list[str] = field(default_factory=list)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class VelocityRules:
|
|
74
|
+
"""Rate limiting for transactions."""
|
|
75
|
+
|
|
76
|
+
max_per_hour: int | None = None
|
|
77
|
+
max_per_day: int | None = None
|
|
78
|
+
cooldown_seconds: float = 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class EscalationRules:
|
|
83
|
+
"""When to escalate to a human."""
|
|
84
|
+
|
|
85
|
+
above_amount: float | None = None
|
|
86
|
+
on_new_merchant: bool = False
|
|
87
|
+
on_new_category: bool = False
|
|
88
|
+
on_cumulative_above: float | None = None # escalate when session total exceeds
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class TimeRestrictions:
|
|
93
|
+
"""When the agent is allowed to transact."""
|
|
94
|
+
|
|
95
|
+
allowed_hours: tuple[int, int] | None = None # (start, end) in 24h
|
|
96
|
+
allowed_days: list[int] | None = None # 0=Mon, 6=Sun
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class Policy:
|
|
101
|
+
"""Complete policy definition for an agent."""
|
|
102
|
+
|
|
103
|
+
name: str = "default"
|
|
104
|
+
version: str = "1"
|
|
105
|
+
limits: PolicyLimits = field(default_factory=PolicyLimits)
|
|
106
|
+
merchants: MerchantRules = field(default_factory=MerchantRules)
|
|
107
|
+
velocity: VelocityRules = field(default_factory=VelocityRules)
|
|
108
|
+
escalation: EscalationRules = field(default_factory=EscalationRules)
|
|
109
|
+
time_restrictions: TimeRestrictions = field(default_factory=TimeRestrictions)
|
|
110
|
+
|
|
111
|
+
# Convenience constructor
|
|
112
|
+
@classmethod
|
|
113
|
+
def simple(
|
|
114
|
+
cls,
|
|
115
|
+
max_per_transaction: float = 50.0,
|
|
116
|
+
max_daily: float = 200.0,
|
|
117
|
+
max_monthly: float = 2000.0,
|
|
118
|
+
allowed_categories: list[str] | None = None,
|
|
119
|
+
blocked_merchants: list[str] | None = None,
|
|
120
|
+
require_escalation_above: float | None = None,
|
|
121
|
+
) -> Policy:
|
|
122
|
+
return cls(
|
|
123
|
+
limits=PolicyLimits(
|
|
124
|
+
per_transaction=max_per_transaction,
|
|
125
|
+
daily=max_daily,
|
|
126
|
+
monthly=max_monthly,
|
|
127
|
+
),
|
|
128
|
+
merchants=MerchantRules(
|
|
129
|
+
allowed_categories=allowed_categories,
|
|
130
|
+
blocked_merchants=blocked_merchants or [],
|
|
131
|
+
),
|
|
132
|
+
escalation=EscalationRules(above_amount=require_escalation_above),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass
|
|
137
|
+
class Check:
|
|
138
|
+
"""Result of a single policy check."""
|
|
139
|
+
|
|
140
|
+
name: str
|
|
141
|
+
result: CheckResult
|
|
142
|
+
message: str
|
|
143
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass
|
|
147
|
+
class EvaluationResult:
|
|
148
|
+
"""Full result of a gate evaluation."""
|
|
149
|
+
|
|
150
|
+
decision: Decision
|
|
151
|
+
transaction: Transaction
|
|
152
|
+
checks: list[Check]
|
|
153
|
+
escalation_reasons: list[str] = field(default_factory=list)
|
|
154
|
+
audit: AuditEntry | None = None
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def passed(self) -> bool:
|
|
158
|
+
return self.decision == Decision.ALLOW
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def failed_checks(self) -> list[Check]:
|
|
162
|
+
return [c for c in self.checks if c.result == CheckResult.FAIL]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass
|
|
166
|
+
class AuditEntry:
|
|
167
|
+
"""Immutable record of a gate decision."""
|
|
168
|
+
|
|
169
|
+
timestamp: datetime
|
|
170
|
+
transaction_id: str
|
|
171
|
+
agent_id: str
|
|
172
|
+
decision: Decision
|
|
173
|
+
checks_passed: list[str]
|
|
174
|
+
checks_failed: list[str]
|
|
175
|
+
escalation_reasons: list[str]
|
|
176
|
+
reasoning: str
|
|
177
|
+
policy_name: str
|
|
178
|
+
policy_version: str
|
|
179
|
+
id: str = field(default_factory=lambda: f"audit_{uuid.uuid4().hex[:12]}")
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pyagentgate
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Open-source policy engine for agentic payments
|
|
5
|
+
Project-URL: Repository, https://github.com/Peterc3-dev/agentgate
|
|
6
|
+
Author: Peter C. Clemente III
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: agent,authorization,fintech,payments,policy
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
15
|
+
Classifier: Topic :: Security
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
19
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
20
|
+
Provides-Extra: yaml
|
|
21
|
+
Requires-Dist: pyyaml>=6.0; extra == 'yaml'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# AgentGate
|
|
25
|
+
|
|
26
|
+
Open-source policy engine for agentic payments. Not a payment processor. Not a toll booth. The authorization layer between an AI agent and whatever payment API it's calling.
|
|
27
|
+
|
|
28
|
+
## Why
|
|
29
|
+
|
|
30
|
+
Every "agentic payments" startup is racing to insert themselves as middleware and clip 1-5 cents per transaction. AgentGate is the open alternative: a framework-agnostic policy engine that lets you define what an agent is allowed to spend, where, how much, and how often — without surrendering control to a rent-seeking intermediary.
|
|
31
|
+
|
|
32
|
+
## What It Does
|
|
33
|
+
|
|
34
|
+
- **Policy Schema** — Define spending rules in YAML or Python: per-transaction limits, daily/weekly/monthly caps, merchant category allowlists/blocklists, time-of-day restrictions
|
|
35
|
+
- **Evaluation Engine** — Takes a proposed transaction + policy → returns `ALLOW`, `DENY`, or `ESCALATE`
|
|
36
|
+
- **Human-in-the-Loop Hooks** — Pluggable escalation when transactions exceed policy bounds
|
|
37
|
+
- **Audit Trail** — Structured log of every decision with agent reasoning attached
|
|
38
|
+
- **Cooldown & Velocity Controls** — Prevent rapid-fire purchasing and runaway loops
|
|
39
|
+
|
|
40
|
+
## What It Doesn't Do
|
|
41
|
+
|
|
42
|
+
- Process payments (use Stripe, Square, PayPal, etc.)
|
|
43
|
+
- Store card numbers or tokens (use your payment provider's vault)
|
|
44
|
+
- Replace PCI compliance (that's between you and your provider)
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install agentgate
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Quick Start
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from agentgate import Policy, Gate, Transaction
|
|
56
|
+
|
|
57
|
+
# Define a policy
|
|
58
|
+
policy = Policy(
|
|
59
|
+
max_per_transaction=50.00,
|
|
60
|
+
max_daily=200.00,
|
|
61
|
+
max_monthly=2000.00,
|
|
62
|
+
allowed_categories=["groceries", "office_supplies", "saas"],
|
|
63
|
+
blocked_merchants=["casino-online.com"],
|
|
64
|
+
require_escalation_above=100.00,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Create a gate
|
|
68
|
+
gate = Gate(policy=policy)
|
|
69
|
+
|
|
70
|
+
# Evaluate a transaction
|
|
71
|
+
tx = Transaction(
|
|
72
|
+
amount=42.99,
|
|
73
|
+
currency="USD",
|
|
74
|
+
merchant="staples.com",
|
|
75
|
+
category="office_supplies",
|
|
76
|
+
agent_id="purchasing-agent-01",
|
|
77
|
+
reasoning="Need printer paper for office",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
result = gate.evaluate(tx)
|
|
81
|
+
# result.decision = Decision.ALLOW
|
|
82
|
+
# result.policy_checks = [...]
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Policy Schema (YAML)
|
|
86
|
+
|
|
87
|
+
```yaml
|
|
88
|
+
version: "1"
|
|
89
|
+
name: office-purchasing-agent
|
|
90
|
+
limits:
|
|
91
|
+
per_transaction: 50.00
|
|
92
|
+
daily: 200.00
|
|
93
|
+
weekly: 750.00
|
|
94
|
+
monthly: 2000.00
|
|
95
|
+
currency: USD
|
|
96
|
+
merchants:
|
|
97
|
+
allowed_categories:
|
|
98
|
+
- office_supplies
|
|
99
|
+
- saas
|
|
100
|
+
- groceries
|
|
101
|
+
blocked:
|
|
102
|
+
- casino-online.com
|
|
103
|
+
- gambling.*
|
|
104
|
+
velocity:
|
|
105
|
+
max_transactions_per_hour: 10
|
|
106
|
+
max_transactions_per_day: 50
|
|
107
|
+
cooldown_seconds: 30
|
|
108
|
+
escalation:
|
|
109
|
+
above_amount: 100.00
|
|
110
|
+
on_new_merchant: true
|
|
111
|
+
on_new_category: true
|
|
112
|
+
time_restrictions:
|
|
113
|
+
allowed_hours: [9, 17] # 9am-5pm only
|
|
114
|
+
allowed_days: [0, 1, 2, 3, 4] # Mon-Fri
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Custom Escalation Handlers
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from agentgate import Gate, EscalationHandler
|
|
121
|
+
|
|
122
|
+
class SlackEscalation(EscalationHandler):
|
|
123
|
+
async def escalate(self, transaction, reasons):
|
|
124
|
+
# Post to Slack, wait for approval
|
|
125
|
+
approved = await self.post_to_slack(transaction, reasons)
|
|
126
|
+
return approved
|
|
127
|
+
|
|
128
|
+
gate = Gate(policy=policy, escalation_handler=SlackEscalation())
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Audit Log
|
|
132
|
+
|
|
133
|
+
Every evaluation produces an `AuditEntry`:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
result = gate.evaluate(tx)
|
|
137
|
+
print(result.audit)
|
|
138
|
+
# AuditEntry(
|
|
139
|
+
# timestamp=2026-08-30T...,
|
|
140
|
+
# transaction_id="tx_abc123",
|
|
141
|
+
# agent_id="purchasing-agent-01",
|
|
142
|
+
# decision=Decision.ALLOW,
|
|
143
|
+
# checks_passed=["amount_limit", "category_allowed", "velocity_ok"],
|
|
144
|
+
# checks_failed=[],
|
|
145
|
+
# reasoning="Need printer paper for office",
|
|
146
|
+
# policy_version="1",
|
|
147
|
+
# )
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Framework Integration
|
|
151
|
+
|
|
152
|
+
AgentGate is framework-agnostic. Use it with any agent framework:
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
# LangChain tool wrapper
|
|
156
|
+
from agentgate.integrations import langchain_tool
|
|
157
|
+
|
|
158
|
+
# CrewAI tool wrapper
|
|
159
|
+
from agentgate.integrations import crewai_tool
|
|
160
|
+
|
|
161
|
+
# Raw function — wrap any payment call
|
|
162
|
+
from agentgate import gated
|
|
163
|
+
|
|
164
|
+
@gated(policy=policy)
|
|
165
|
+
async def buy_item(merchant, amount, item):
|
|
166
|
+
return await stripe.checkout(...)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## License
|
|
170
|
+
|
|
171
|
+
MIT — because the whole point is that nobody gets to gatekeep this.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
agentgate/__init__.py,sha256=23XPwkDvyrOXo89VJMk3m4mQvofRnPOzcCoxoHxmdHk,1117
|
|
2
|
+
agentgate/checks.py,sha256=ZTIVQuG4FdN1TkbHlRn9BiLNum5xwTPs0VwugZnxOys,9973
|
|
3
|
+
agentgate/decorators.py,sha256=0Ml4z8ZBlDXIx9kcK5JZLOZ9xkAUKTFQlLBNKhrJDiM,4187
|
|
4
|
+
agentgate/gate.py,sha256=VMAGNAg75GjwzxW0Qh9eRYYX1hAjEMJo2jgC1U8bWBI,4032
|
|
5
|
+
agentgate/ledger.py,sha256=STf4TyfFJrO-OcRkJ_HmKEadc0KqnzrwAwlfGP_-5KI,2752
|
|
6
|
+
agentgate/loader.py,sha256=7xcvF1jqsxESophjB984-ginKqbZ1GwfKUZGWsVjlTg,3036
|
|
7
|
+
agentgate/models.py,sha256=KzHB_qu3Zmq9q25BFvJ79DnvxKpR5tu9aGmVPr5OKHg,4825
|
|
8
|
+
pyagentgate-0.1.0.dist-info/METADATA,sha256=1XjFpMN-sKY5_e-oFgaP7JTm99QE1k1wU2ALqrlzcSY,4815
|
|
9
|
+
pyagentgate-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
10
|
+
pyagentgate-0.1.0.dist-info/licenses/LICENSE,sha256=mik7Go9iYdliXSawk4ipL6BXJh0FTu5GEyJtZQFJBzA,1078
|
|
11
|
+
pyagentgate-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Peter C. Clemente III
|
|
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.
|