pyagentgate 0.1.0__tar.gz
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.
- pyagentgate-0.1.0/.github/workflows/test.yml +17 -0
- pyagentgate-0.1.0/LICENSE +21 -0
- pyagentgate-0.1.0/PKG-INFO +171 -0
- pyagentgate-0.1.0/README.md +148 -0
- pyagentgate-0.1.0/agentgate/__init__.py +53 -0
- pyagentgate-0.1.0/agentgate/checks.py +257 -0
- pyagentgate-0.1.0/agentgate/decorators.py +129 -0
- pyagentgate-0.1.0/agentgate/gate.py +131 -0
- pyagentgate-0.1.0/agentgate/ledger.py +74 -0
- pyagentgate-0.1.0/agentgate/loader.py +101 -0
- pyagentgate-0.1.0/agentgate/models.py +179 -0
- pyagentgate-0.1.0/examples/basic_usage.py +64 -0
- pyagentgate-0.1.0/pyproject.toml +31 -0
- pyagentgate-0.1.0/tests/__init__.py +0 -0
- pyagentgate-0.1.0/tests/test_gate.py +309 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
|
|
3
|
+
on: [push, pull_request]
|
|
4
|
+
|
|
5
|
+
jobs:
|
|
6
|
+
test:
|
|
7
|
+
runs-on: ubuntu-latest
|
|
8
|
+
strategy:
|
|
9
|
+
matrix:
|
|
10
|
+
python-version: ["3.10", "3.11", "3.12"]
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- uses: actions/setup-python@v5
|
|
14
|
+
with:
|
|
15
|
+
python-version: ${{ matrix.python-version }}
|
|
16
|
+
- run: pip install -e ".[dev]"
|
|
17
|
+
- run: pytest tests/ -v
|
|
@@ -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.
|
|
@@ -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,148 @@
|
|
|
1
|
+
# AgentGate
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
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.
|
|
8
|
+
|
|
9
|
+
## What It Does
|
|
10
|
+
|
|
11
|
+
- **Policy Schema** — Define spending rules in YAML or Python: per-transaction limits, daily/weekly/monthly caps, merchant category allowlists/blocklists, time-of-day restrictions
|
|
12
|
+
- **Evaluation Engine** — Takes a proposed transaction + policy → returns `ALLOW`, `DENY`, or `ESCALATE`
|
|
13
|
+
- **Human-in-the-Loop Hooks** — Pluggable escalation when transactions exceed policy bounds
|
|
14
|
+
- **Audit Trail** — Structured log of every decision with agent reasoning attached
|
|
15
|
+
- **Cooldown & Velocity Controls** — Prevent rapid-fire purchasing and runaway loops
|
|
16
|
+
|
|
17
|
+
## What It Doesn't Do
|
|
18
|
+
|
|
19
|
+
- Process payments (use Stripe, Square, PayPal, etc.)
|
|
20
|
+
- Store card numbers or tokens (use your payment provider's vault)
|
|
21
|
+
- Replace PCI compliance (that's between you and your provider)
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install agentgate
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Quick Start
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from agentgate import Policy, Gate, Transaction
|
|
33
|
+
|
|
34
|
+
# Define a policy
|
|
35
|
+
policy = Policy(
|
|
36
|
+
max_per_transaction=50.00,
|
|
37
|
+
max_daily=200.00,
|
|
38
|
+
max_monthly=2000.00,
|
|
39
|
+
allowed_categories=["groceries", "office_supplies", "saas"],
|
|
40
|
+
blocked_merchants=["casino-online.com"],
|
|
41
|
+
require_escalation_above=100.00,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Create a gate
|
|
45
|
+
gate = Gate(policy=policy)
|
|
46
|
+
|
|
47
|
+
# Evaluate a transaction
|
|
48
|
+
tx = Transaction(
|
|
49
|
+
amount=42.99,
|
|
50
|
+
currency="USD",
|
|
51
|
+
merchant="staples.com",
|
|
52
|
+
category="office_supplies",
|
|
53
|
+
agent_id="purchasing-agent-01",
|
|
54
|
+
reasoning="Need printer paper for office",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
result = gate.evaluate(tx)
|
|
58
|
+
# result.decision = Decision.ALLOW
|
|
59
|
+
# result.policy_checks = [...]
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Policy Schema (YAML)
|
|
63
|
+
|
|
64
|
+
```yaml
|
|
65
|
+
version: "1"
|
|
66
|
+
name: office-purchasing-agent
|
|
67
|
+
limits:
|
|
68
|
+
per_transaction: 50.00
|
|
69
|
+
daily: 200.00
|
|
70
|
+
weekly: 750.00
|
|
71
|
+
monthly: 2000.00
|
|
72
|
+
currency: USD
|
|
73
|
+
merchants:
|
|
74
|
+
allowed_categories:
|
|
75
|
+
- office_supplies
|
|
76
|
+
- saas
|
|
77
|
+
- groceries
|
|
78
|
+
blocked:
|
|
79
|
+
- casino-online.com
|
|
80
|
+
- gambling.*
|
|
81
|
+
velocity:
|
|
82
|
+
max_transactions_per_hour: 10
|
|
83
|
+
max_transactions_per_day: 50
|
|
84
|
+
cooldown_seconds: 30
|
|
85
|
+
escalation:
|
|
86
|
+
above_amount: 100.00
|
|
87
|
+
on_new_merchant: true
|
|
88
|
+
on_new_category: true
|
|
89
|
+
time_restrictions:
|
|
90
|
+
allowed_hours: [9, 17] # 9am-5pm only
|
|
91
|
+
allowed_days: [0, 1, 2, 3, 4] # Mon-Fri
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Custom Escalation Handlers
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from agentgate import Gate, EscalationHandler
|
|
98
|
+
|
|
99
|
+
class SlackEscalation(EscalationHandler):
|
|
100
|
+
async def escalate(self, transaction, reasons):
|
|
101
|
+
# Post to Slack, wait for approval
|
|
102
|
+
approved = await self.post_to_slack(transaction, reasons)
|
|
103
|
+
return approved
|
|
104
|
+
|
|
105
|
+
gate = Gate(policy=policy, escalation_handler=SlackEscalation())
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Audit Log
|
|
109
|
+
|
|
110
|
+
Every evaluation produces an `AuditEntry`:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
result = gate.evaluate(tx)
|
|
114
|
+
print(result.audit)
|
|
115
|
+
# AuditEntry(
|
|
116
|
+
# timestamp=2026-08-30T...,
|
|
117
|
+
# transaction_id="tx_abc123",
|
|
118
|
+
# agent_id="purchasing-agent-01",
|
|
119
|
+
# decision=Decision.ALLOW,
|
|
120
|
+
# checks_passed=["amount_limit", "category_allowed", "velocity_ok"],
|
|
121
|
+
# checks_failed=[],
|
|
122
|
+
# reasoning="Need printer paper for office",
|
|
123
|
+
# policy_version="1",
|
|
124
|
+
# )
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Framework Integration
|
|
128
|
+
|
|
129
|
+
AgentGate is framework-agnostic. Use it with any agent framework:
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
# LangChain tool wrapper
|
|
133
|
+
from agentgate.integrations import langchain_tool
|
|
134
|
+
|
|
135
|
+
# CrewAI tool wrapper
|
|
136
|
+
from agentgate.integrations import crewai_tool
|
|
137
|
+
|
|
138
|
+
# Raw function — wrap any payment call
|
|
139
|
+
from agentgate import gated
|
|
140
|
+
|
|
141
|
+
@gated(policy=policy)
|
|
142
|
+
async def buy_item(merchant, amount, item):
|
|
143
|
+
return await stripe.checkout(...)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
MIT — because the whole point is that nobody gets to gatekeep this.
|
|
@@ -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
|
+
]
|
|
@@ -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
|
+
]
|