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.
@@ -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
+ ]