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 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
+ ]
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.