agent-email-guard 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.
- agent_email_guard-0.1.0/.gitignore +7 -0
- agent_email_guard-0.1.0/LICENSE +21 -0
- agent_email_guard-0.1.0/PKG-INFO +202 -0
- agent_email_guard-0.1.0/README.md +169 -0
- agent_email_guard-0.1.0/examples/agent_tool.py +57 -0
- agent_email_guard-0.1.0/examples/agentisend_sdk.py +30 -0
- agent_email_guard-0.1.0/examples/boto3_ses.py +43 -0
- agent_email_guard-0.1.0/examples/resend_sdk.py +29 -0
- agent_email_guard-0.1.0/examples/smtp_stdlib.py +49 -0
- agent_email_guard-0.1.0/pyproject.toml +48 -0
- agent_email_guard-0.1.0/src/agent_email_guard/__init__.py +58 -0
- agent_email_guard-0.1.0/src/agent_email_guard/_version.py +1 -0
- agent_email_guard-0.1.0/src/agent_email_guard/agentisend.py +300 -0
- agent_email_guard-0.1.0/src/agent_email_guard/decisions.py +119 -0
- agent_email_guard-0.1.0/src/agent_email_guard/errors.py +38 -0
- agent_email_guard-0.1.0/src/agent_email_guard/guard.py +613 -0
- agent_email_guard-0.1.0/src/agent_email_guard/loop.py +286 -0
- agent_email_guard-0.1.0/src/agent_email_guard/messages.py +103 -0
- agent_email_guard-0.1.0/src/agent_email_guard/policy.py +116 -0
- agent_email_guard-0.1.0/src/agent_email_guard/py.typed +0 -0
- agent_email_guard-0.1.0/src/agent_email_guard/stores/__init__.py +10 -0
- agent_email_guard-0.1.0/src/agent_email_guard/stores/base.py +96 -0
- agent_email_guard-0.1.0/src/agent_email_guard/stores/memory.py +119 -0
- agent_email_guard-0.1.0/src/agent_email_guard/stores/postgres.py +250 -0
- agent_email_guard-0.1.0/src/agent_email_guard/stores/redis.py +192 -0
- agent_email_guard-0.1.0/src/agent_email_guard/text.py +93 -0
- agent_email_guard-0.1.0/tests/conftest.py +16 -0
- agent_email_guard-0.1.0/tests/test_agentisend_adapter.py +138 -0
- agent_email_guard-0.1.0/tests/test_b08_budget_race.py +124 -0
- agent_email_guard-0.1.0/tests/test_diverse_traffic_not_flagged.py +81 -0
- agent_email_guard-0.1.0/tests/test_entropy_collapse_independent.py +67 -0
- agent_email_guard-0.1.0/tests/test_examples.py +107 -0
- agent_email_guard-0.1.0/tests/test_guard_api.py +170 -0
- agent_email_guard-0.1.0/tests/test_kill_switch_no_auto_resume.py +74 -0
- agent_email_guard-0.1.0/tests/test_loop_4000_repetition.py +49 -0
- agent_email_guard-0.1.0/tests/test_loop_mode_repetition_only.py +42 -0
- agent_email_guard-0.1.0/tests/test_policy_file.py +68 -0
- agent_email_guard-0.1.0/tests/test_readme.py +29 -0
- agent_email_guard-0.1.0/tests/test_refusal_tokens.py +72 -0
- agent_email_guard-0.1.0/tests/test_sp04_entropy_collapse_dominant_only.py +70 -0
- agent_email_guard-0.1.0/tests/test_spec_vectors.py +110 -0
- agent_email_guard-0.1.0/tests/test_stores.py +215 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AgentiSend
|
|
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,202 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: agent-email-guard
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Blast-radius control for agents that send email: budgets, a rate ceiling, loop detection, approval holds and a kill switch, in front of any provider.
|
|
5
|
+
Project-URL: Homepage, https://agentisend.com
|
|
6
|
+
Project-URL: Source, https://github.com/agentisend/agent-email-guard
|
|
7
|
+
Project-URL: Issues, https://github.com/agentisend/agent-email-guard/issues
|
|
8
|
+
Author-email: AgentiSend <hello@agentisend.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai-agents,budget,email,guardrails,kill-switch,loop-detection,mcp,rate-limit,resend-alternative
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Communications :: Email
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: fakeredis[lua]>=2.20; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
28
|
+
Provides-Extra: postgres
|
|
29
|
+
Requires-Dist: psycopg>=3.1; extra == 'postgres'
|
|
30
|
+
Provides-Extra: redis
|
|
31
|
+
Requires-Dist: redis>=4.2; extra == 'redis'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# agent-email-guard
|
|
35
|
+
|
|
36
|
+
Blast-radius control for agents that send email. It caps how much an agent can send and to whom, stops loops, holds chosen sends for a person, and gives you one call that stops everything. It works in front of any provider: AgentiSend, Resend, Amazon SES, SMTP, or your own function.
|
|
37
|
+
|
|
38
|
+
It limits volume, repetition and recipients. It does not read intent or detect prompt injection; an agent tricked into sending one bad email within its limits still sends it.
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install agent-email-guard
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Python 3.9 or newer. No runtime dependencies. Redis and Postgres stores are optional extras.
|
|
45
|
+
|
|
46
|
+
## Twelve lines
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from agent_email_guard import ApprovalRequired, Guard, GuardRefused
|
|
50
|
+
|
|
51
|
+
guard = Guard(
|
|
52
|
+
budgets={"per_hour": 50, "per_day": 500},
|
|
53
|
+
rate={"per_minute": 10},
|
|
54
|
+
suppress=["@competitor.example"],
|
|
55
|
+
)
|
|
56
|
+
send = guard.wrap(provider.send) # any function that takes one email dict
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
send({"to": "customer@example.com", "subject": "Your receipt", "text": "Thanks."})
|
|
60
|
+
except (GuardRefused, ApprovalRequired) as stop:
|
|
61
|
+
print(stop.decision.reason, stop.decision.message)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`wrap()` keeps the function's signature. Each call is checked, sent, and recorded. If the provider raises, the reservation is given back and the provider's error reaches you unchanged.
|
|
65
|
+
|
|
66
|
+
## What it decides
|
|
67
|
+
|
|
68
|
+
Every check returns a `Decision` with four fields. `outcome` is `allow`, `hold_for_approval` or `refuse`. `reason` is a stable token. `message` is one sentence a person can read. `evidence` holds the numbers behind it:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
>>> guard.check({"to": "ops@example.com", "subject": "Disk at 91%", "text": "db-1"})
|
|
72
|
+
Decision(outcome='refuse', reason='loop_repetition_detected',
|
|
73
|
+
message='Blocked: 3 near-identical emails already went to ops@example.com within 60 minutes, which matches a retry or script loop; review the agent before sending more.',
|
|
74
|
+
evidence={'matching_sends_in_window': 3, 'repetition_threshold': 3, 'window_ms': 3600000, ...})
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Checks run in this order and stop at the first block:
|
|
78
|
+
|
|
79
|
+
| # | Check | Outcome | Reason |
|
|
80
|
+
|---|---|---|---|
|
|
81
|
+
| 1 | Kill switch | refuse | `kill_switch_active` |
|
|
82
|
+
| 2 | Suppression list | refuse | `recipient_suppressed` |
|
|
83
|
+
| 3 | Loop detection | refuse | `loop_repetition_detected`, `recipient_entropy_collapse` |
|
|
84
|
+
| 4 | Approval rules | hold | `recipient_requires_approval` |
|
|
85
|
+
| 5 | Rate ceiling (sliding minute) | refuse | `rate_ceiling_exceeded` |
|
|
86
|
+
| 6 | Budgets (UTC hour, day, month) | refuse | `agent_budget_exceeded` |
|
|
87
|
+
| 7 | | allow | `ok` |
|
|
88
|
+
|
|
89
|
+
Steps 1 to 4 reserve nothing. Steps 5 and 6 reserve atomically: 200 concurrent checks against a budget of 50 allow exactly 50 (the test suite runs this with threads and with asyncio).
|
|
90
|
+
|
|
91
|
+
**Loop detection** uses two signals. The first is repetition: the agent has already sent 3 emails to the same To line within 60 minutes, with subject and body at least 85% similar (trigram Jaccard). The second is recipient collapse: across 20 or more recent sends, the effective number of recipients has dropped below 3. A collapsed window holds only a send to one of the inboxes it collapsed on. A sign-in code to a new user still goes out. Both signals and all five thresholds are configurable (`LOOP_DEFAULTS`). `loop={"mode": "repetition_only"}` turns off the collapse signal, and `loop=False` turns off both.
|
|
92
|
+
|
|
93
|
+
**Approvals.** `approval={"recipients": ["@bigcustomer.com"]}` holds any send to that domain. `approval={"new_recipients": True}` holds a send to any address this key has never sent to. `approval={"require_for": fn}` holds a send whenever your function returns something truthy. Keep held sends wherever you like, then call `stop.approve()` once a person says yes. Every other check runs again before the email goes out.
|
|
94
|
+
|
|
95
|
+
**Kill switch.** `guard.kill_switch.pause("looping on one inbox", by="ops")` stops every send through this guard, for every key, until someone calls `guard.kill_switch.resume("ops")`. There is no timer. A paused guard is still paused a year later.
|
|
96
|
+
|
|
97
|
+
**Keys.** A send can name the agent it belongs to with `"key": "billing-agent"`. Each key gets its own budget, rate window, loop window and seen-recipient memory.
|
|
98
|
+
|
|
99
|
+
## Policy file
|
|
100
|
+
|
|
101
|
+
Keep the limits in `agent-email-guard.json` next to your code instead of inside it. The same file configures the Node, PHP and Go ports. An unknown key or a wrong type rejects the whole file with a sentence naming the key.
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
{
|
|
105
|
+
"version": 1,
|
|
106
|
+
"key": "support-agent",
|
|
107
|
+
"budgets": {"per_hour": 50, "per_day": 500},
|
|
108
|
+
"rate": {"per_minute": 10},
|
|
109
|
+
"approval": {"new_recipients": true, "recipients": ["@bigcustomer.com"]},
|
|
110
|
+
"suppress": ["@competitor.example"]
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
guard = Guard.from_policy() # reads ./agent-email-guard.json
|
|
116
|
+
guard = Guard.from_policy("config/agent-email-guard.json", require_for=my_rule, store=store)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`require_for` is code, so it is passed in, never read from the file. Keyword arguments override the file.
|
|
120
|
+
|
|
121
|
+
## Advisory here, enforced at AgentiSend
|
|
122
|
+
|
|
123
|
+
This library runs inside the agent's own process. That makes it advisory. An agent that edits its own code, skips `check()`, or starts a second process with a fresh in-memory store can get around it. For many teams that is enough: the failure it catches is the agent stuck in a retry loop, not an agent fighting its operator.
|
|
124
|
+
|
|
125
|
+
When you need limits the agent cannot remove, AgentiSend enforces the same rules at its API, on the API key itself. The rules are budget, per-minute ceiling, loop detection, approval queue and kill switch. Every process using that key shares them, and a paused key cannot resume itself: only a person signed in to the console can.
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
from agent_email_guard.agentisend import AgentiSendGuard
|
|
129
|
+
|
|
130
|
+
hosted = AgentiSendGuard(key_id="your-api-key-id") # reads AGENTISEND_API_KEY
|
|
131
|
+
hosted.apply_limits(budgets={"per_day": 500}, rate={"per_minute": 10})
|
|
132
|
+
hosted.check(email) # POST /emails/preflight: runs every gate, sends nothing
|
|
133
|
+
hosted.send(email) # POST /emails: raises GuardRefused / ApprovalRequired like the local guard
|
|
134
|
+
hosted.kill_switch.pause("looping")
|
|
135
|
+
hosted.approvals.list() # held sends waiting for a person
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
A key can lower its own limits. Raising a limit is a person's decision, made in the console.
|
|
139
|
+
|
|
140
|
+
## Integrations
|
|
141
|
+
|
|
142
|
+
Each one is a runnable file in [`examples/`](examples/). The test suite runs every example against a stub.
|
|
143
|
+
|
|
144
|
+
- **AgentiSend** ([`agentisend_sdk.py`](examples/agentisend_sdk.py)): `guard.wrap(AgentiSend().send_email)`, with the hosted adapter above for enforcement.
|
|
145
|
+
- **Resend** ([`resend_sdk.py`](examples/resend_sdk.py)): `guard.wrap(resend.Emails.send)`.
|
|
146
|
+
- **Amazon SES** ([`boto3_ses.py`](examples/boto3_ses.py)): `guard.wrap(ses.send_email, extract=...)`, which maps SES's keyword arguments to a send.
|
|
147
|
+
- **SMTP** ([`smtp_stdlib.py`](examples/smtp_stdlib.py)): `smtplib` and `email.message`, standard library only.
|
|
148
|
+
- **LangChain and the OpenAI Agents SDK** ([`agent_tool.py`](examples/agent_tool.py)): a `send_email` tool that returns the guard's sentence instead of raising. The model reads why a send was refused and does not retry in a loop.
|
|
149
|
+
|
|
150
|
+
## Sharing limits across processes
|
|
151
|
+
|
|
152
|
+
The in-memory store counts for one process. To share budgets, the rate window, the loop log and the kill switch across processes, use one of these:
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
# pip install "agent-email-guard[redis]"
|
|
156
|
+
import redis
|
|
157
|
+
from agent_email_guard.stores.redis import RedisStore
|
|
158
|
+
guard = Guard(budgets={"per_day": 500}, store=RedisStore(redis.Redis.from_url("redis://localhost:6379/0")))
|
|
159
|
+
|
|
160
|
+
# pip install "agent-email-guard[postgres]"
|
|
161
|
+
import psycopg
|
|
162
|
+
from agent_email_guard.stores.postgres import PostgresStore
|
|
163
|
+
store = PostgresStore(psycopg.connect("postgresql://localhost/app", autocommit=True))
|
|
164
|
+
store.setup() # creates four agent_email_guard_* tables if missing
|
|
165
|
+
guard = Guard(budgets={"per_day": 500}, store=store)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Redis runs every check-and-reserve as one Lua script. Postgres uses one conditional upsert per counter, plus an advisory lock around the rate window. Store key names are shared with the Node, PHP and Go ports of agent-email-guard, so agents written in different languages can share one budget.
|
|
169
|
+
|
|
170
|
+
## asyncio
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
from agent_email_guard import AsyncGuard
|
|
174
|
+
from agent_email_guard.stores.redis import AsyncRedisStore # or AsyncPostgresStore, or the default in-memory store
|
|
175
|
+
|
|
176
|
+
guard = AsyncGuard(budgets={"per_day": 500}, rate={"per_minute": 10})
|
|
177
|
+
send = guard.wrap(async_provider_send) # wraps an async function
|
|
178
|
+
decision = await guard.check(email) # require_for may be async here
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Without wrap
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
decision = guard.check(email)
|
|
185
|
+
if decision.outcome == "allow":
|
|
186
|
+
try:
|
|
187
|
+
provider.send(email)
|
|
188
|
+
except Exception:
|
|
189
|
+
guard.release(decision) # give back the reservation
|
|
190
|
+
raise
|
|
191
|
+
guard.record(email) # feeds loop detection and new-recipient memory
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
`decision.reservation.to_dict()` is plain JSON, so a decision can be released from a different process.
|
|
195
|
+
|
|
196
|
+
## Conformance
|
|
197
|
+
|
|
198
|
+
Every port of agent-email-guard (Node, Python, PHP, Go) passes the same 45 conformance cases in [`../spec/vectors`](../spec/vectors). They pin each reason token, each sentence byte for byte, and each evidence field. The specification is [`../spec/SPEC.md`](../spec/SPEC.md).
|
|
199
|
+
|
|
200
|
+
## License
|
|
201
|
+
|
|
202
|
+
MIT. Copyright AgentiSend.
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# agent-email-guard
|
|
2
|
+
|
|
3
|
+
Blast-radius control for agents that send email. It caps how much an agent can send and to whom, stops loops, holds chosen sends for a person, and gives you one call that stops everything. It works in front of any provider: AgentiSend, Resend, Amazon SES, SMTP, or your own function.
|
|
4
|
+
|
|
5
|
+
It limits volume, repetition and recipients. It does not read intent or detect prompt injection; an agent tricked into sending one bad email within its limits still sends it.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install agent-email-guard
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Python 3.9 or newer. No runtime dependencies. Redis and Postgres stores are optional extras.
|
|
12
|
+
|
|
13
|
+
## Twelve lines
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from agent_email_guard import ApprovalRequired, Guard, GuardRefused
|
|
17
|
+
|
|
18
|
+
guard = Guard(
|
|
19
|
+
budgets={"per_hour": 50, "per_day": 500},
|
|
20
|
+
rate={"per_minute": 10},
|
|
21
|
+
suppress=["@competitor.example"],
|
|
22
|
+
)
|
|
23
|
+
send = guard.wrap(provider.send) # any function that takes one email dict
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
send({"to": "customer@example.com", "subject": "Your receipt", "text": "Thanks."})
|
|
27
|
+
except (GuardRefused, ApprovalRequired) as stop:
|
|
28
|
+
print(stop.decision.reason, stop.decision.message)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`wrap()` keeps the function's signature. Each call is checked, sent, and recorded. If the provider raises, the reservation is given back and the provider's error reaches you unchanged.
|
|
32
|
+
|
|
33
|
+
## What it decides
|
|
34
|
+
|
|
35
|
+
Every check returns a `Decision` with four fields. `outcome` is `allow`, `hold_for_approval` or `refuse`. `reason` is a stable token. `message` is one sentence a person can read. `evidence` holds the numbers behind it:
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
>>> guard.check({"to": "ops@example.com", "subject": "Disk at 91%", "text": "db-1"})
|
|
39
|
+
Decision(outcome='refuse', reason='loop_repetition_detected',
|
|
40
|
+
message='Blocked: 3 near-identical emails already went to ops@example.com within 60 minutes, which matches a retry or script loop; review the agent before sending more.',
|
|
41
|
+
evidence={'matching_sends_in_window': 3, 'repetition_threshold': 3, 'window_ms': 3600000, ...})
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Checks run in this order and stop at the first block:
|
|
45
|
+
|
|
46
|
+
| # | Check | Outcome | Reason |
|
|
47
|
+
|---|---|---|---|
|
|
48
|
+
| 1 | Kill switch | refuse | `kill_switch_active` |
|
|
49
|
+
| 2 | Suppression list | refuse | `recipient_suppressed` |
|
|
50
|
+
| 3 | Loop detection | refuse | `loop_repetition_detected`, `recipient_entropy_collapse` |
|
|
51
|
+
| 4 | Approval rules | hold | `recipient_requires_approval` |
|
|
52
|
+
| 5 | Rate ceiling (sliding minute) | refuse | `rate_ceiling_exceeded` |
|
|
53
|
+
| 6 | Budgets (UTC hour, day, month) | refuse | `agent_budget_exceeded` |
|
|
54
|
+
| 7 | | allow | `ok` |
|
|
55
|
+
|
|
56
|
+
Steps 1 to 4 reserve nothing. Steps 5 and 6 reserve atomically: 200 concurrent checks against a budget of 50 allow exactly 50 (the test suite runs this with threads and with asyncio).
|
|
57
|
+
|
|
58
|
+
**Loop detection** uses two signals. The first is repetition: the agent has already sent 3 emails to the same To line within 60 minutes, with subject and body at least 85% similar (trigram Jaccard). The second is recipient collapse: across 20 or more recent sends, the effective number of recipients has dropped below 3. A collapsed window holds only a send to one of the inboxes it collapsed on. A sign-in code to a new user still goes out. Both signals and all five thresholds are configurable (`LOOP_DEFAULTS`). `loop={"mode": "repetition_only"}` turns off the collapse signal, and `loop=False` turns off both.
|
|
59
|
+
|
|
60
|
+
**Approvals.** `approval={"recipients": ["@bigcustomer.com"]}` holds any send to that domain. `approval={"new_recipients": True}` holds a send to any address this key has never sent to. `approval={"require_for": fn}` holds a send whenever your function returns something truthy. Keep held sends wherever you like, then call `stop.approve()` once a person says yes. Every other check runs again before the email goes out.
|
|
61
|
+
|
|
62
|
+
**Kill switch.** `guard.kill_switch.pause("looping on one inbox", by="ops")` stops every send through this guard, for every key, until someone calls `guard.kill_switch.resume("ops")`. There is no timer. A paused guard is still paused a year later.
|
|
63
|
+
|
|
64
|
+
**Keys.** A send can name the agent it belongs to with `"key": "billing-agent"`. Each key gets its own budget, rate window, loop window and seen-recipient memory.
|
|
65
|
+
|
|
66
|
+
## Policy file
|
|
67
|
+
|
|
68
|
+
Keep the limits in `agent-email-guard.json` next to your code instead of inside it. The same file configures the Node, PHP and Go ports. An unknown key or a wrong type rejects the whole file with a sentence naming the key.
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"version": 1,
|
|
73
|
+
"key": "support-agent",
|
|
74
|
+
"budgets": {"per_hour": 50, "per_day": 500},
|
|
75
|
+
"rate": {"per_minute": 10},
|
|
76
|
+
"approval": {"new_recipients": true, "recipients": ["@bigcustomer.com"]},
|
|
77
|
+
"suppress": ["@competitor.example"]
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
guard = Guard.from_policy() # reads ./agent-email-guard.json
|
|
83
|
+
guard = Guard.from_policy("config/agent-email-guard.json", require_for=my_rule, store=store)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`require_for` is code, so it is passed in, never read from the file. Keyword arguments override the file.
|
|
87
|
+
|
|
88
|
+
## Advisory here, enforced at AgentiSend
|
|
89
|
+
|
|
90
|
+
This library runs inside the agent's own process. That makes it advisory. An agent that edits its own code, skips `check()`, or starts a second process with a fresh in-memory store can get around it. For many teams that is enough: the failure it catches is the agent stuck in a retry loop, not an agent fighting its operator.
|
|
91
|
+
|
|
92
|
+
When you need limits the agent cannot remove, AgentiSend enforces the same rules at its API, on the API key itself. The rules are budget, per-minute ceiling, loop detection, approval queue and kill switch. Every process using that key shares them, and a paused key cannot resume itself: only a person signed in to the console can.
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from agent_email_guard.agentisend import AgentiSendGuard
|
|
96
|
+
|
|
97
|
+
hosted = AgentiSendGuard(key_id="your-api-key-id") # reads AGENTISEND_API_KEY
|
|
98
|
+
hosted.apply_limits(budgets={"per_day": 500}, rate={"per_minute": 10})
|
|
99
|
+
hosted.check(email) # POST /emails/preflight: runs every gate, sends nothing
|
|
100
|
+
hosted.send(email) # POST /emails: raises GuardRefused / ApprovalRequired like the local guard
|
|
101
|
+
hosted.kill_switch.pause("looping")
|
|
102
|
+
hosted.approvals.list() # held sends waiting for a person
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
A key can lower its own limits. Raising a limit is a person's decision, made in the console.
|
|
106
|
+
|
|
107
|
+
## Integrations
|
|
108
|
+
|
|
109
|
+
Each one is a runnable file in [`examples/`](examples/). The test suite runs every example against a stub.
|
|
110
|
+
|
|
111
|
+
- **AgentiSend** ([`agentisend_sdk.py`](examples/agentisend_sdk.py)): `guard.wrap(AgentiSend().send_email)`, with the hosted adapter above for enforcement.
|
|
112
|
+
- **Resend** ([`resend_sdk.py`](examples/resend_sdk.py)): `guard.wrap(resend.Emails.send)`.
|
|
113
|
+
- **Amazon SES** ([`boto3_ses.py`](examples/boto3_ses.py)): `guard.wrap(ses.send_email, extract=...)`, which maps SES's keyword arguments to a send.
|
|
114
|
+
- **SMTP** ([`smtp_stdlib.py`](examples/smtp_stdlib.py)): `smtplib` and `email.message`, standard library only.
|
|
115
|
+
- **LangChain and the OpenAI Agents SDK** ([`agent_tool.py`](examples/agent_tool.py)): a `send_email` tool that returns the guard's sentence instead of raising. The model reads why a send was refused and does not retry in a loop.
|
|
116
|
+
|
|
117
|
+
## Sharing limits across processes
|
|
118
|
+
|
|
119
|
+
The in-memory store counts for one process. To share budgets, the rate window, the loop log and the kill switch across processes, use one of these:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
# pip install "agent-email-guard[redis]"
|
|
123
|
+
import redis
|
|
124
|
+
from agent_email_guard.stores.redis import RedisStore
|
|
125
|
+
guard = Guard(budgets={"per_day": 500}, store=RedisStore(redis.Redis.from_url("redis://localhost:6379/0")))
|
|
126
|
+
|
|
127
|
+
# pip install "agent-email-guard[postgres]"
|
|
128
|
+
import psycopg
|
|
129
|
+
from agent_email_guard.stores.postgres import PostgresStore
|
|
130
|
+
store = PostgresStore(psycopg.connect("postgresql://localhost/app", autocommit=True))
|
|
131
|
+
store.setup() # creates four agent_email_guard_* tables if missing
|
|
132
|
+
guard = Guard(budgets={"per_day": 500}, store=store)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Redis runs every check-and-reserve as one Lua script. Postgres uses one conditional upsert per counter, plus an advisory lock around the rate window. Store key names are shared with the Node, PHP and Go ports of agent-email-guard, so agents written in different languages can share one budget.
|
|
136
|
+
|
|
137
|
+
## asyncio
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
from agent_email_guard import AsyncGuard
|
|
141
|
+
from agent_email_guard.stores.redis import AsyncRedisStore # or AsyncPostgresStore, or the default in-memory store
|
|
142
|
+
|
|
143
|
+
guard = AsyncGuard(budgets={"per_day": 500}, rate={"per_minute": 10})
|
|
144
|
+
send = guard.wrap(async_provider_send) # wraps an async function
|
|
145
|
+
decision = await guard.check(email) # require_for may be async here
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Without wrap
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
decision = guard.check(email)
|
|
152
|
+
if decision.outcome == "allow":
|
|
153
|
+
try:
|
|
154
|
+
provider.send(email)
|
|
155
|
+
except Exception:
|
|
156
|
+
guard.release(decision) # give back the reservation
|
|
157
|
+
raise
|
|
158
|
+
guard.record(email) # feeds loop detection and new-recipient memory
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`decision.reservation.to_dict()` is plain JSON, so a decision can be released from a different process.
|
|
162
|
+
|
|
163
|
+
## Conformance
|
|
164
|
+
|
|
165
|
+
Every port of agent-email-guard (Node, Python, PHP, Go) passes the same 45 conformance cases in [`../spec/vectors`](../spec/vectors). They pin each reason token, each sentence byte for byte, and each evidence field. The specification is [`../spec/SPEC.md`](../spec/SPEC.md).
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
MIT. Copyright AgentiSend.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""A send-email tool for an LLM agent, guarded (LangChain or the OpenAI Agents SDK).
|
|
2
|
+
|
|
3
|
+
The tool returns the guard's sentence instead of raising, so the model reads
|
|
4
|
+
why a send was refused or held and can tell the user, rather than retrying
|
|
5
|
+
in a loop. Register the same function with either framework:
|
|
6
|
+
|
|
7
|
+
pip install agent-email-guard langchain-core # LangChain
|
|
8
|
+
pip install agent-email-guard openai-agents # OpenAI Agents SDK
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from agent_email_guard import ApprovalRequired, Guard, GuardRefused
|
|
12
|
+
|
|
13
|
+
guard = Guard(
|
|
14
|
+
budgets={"per_hour": 20, "per_day": 100},
|
|
15
|
+
rate={"per_minute": 5},
|
|
16
|
+
approval={"new_recipients": True},
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
#: Held sends, keyed by the recipient, for a person to approve later.
|
|
20
|
+
held = {}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def make_tool(provider_send, guard=guard):
|
|
24
|
+
guarded = guard.wrap(provider_send)
|
|
25
|
+
|
|
26
|
+
def send_email(to: str, subject: str, body: str) -> str:
|
|
27
|
+
"""Send one email. Returns what happened, in a sentence."""
|
|
28
|
+
try:
|
|
29
|
+
result = guarded({"to": to, "subject": subject, "text": body})
|
|
30
|
+
return f"Sent ({result})."
|
|
31
|
+
except GuardRefused as stop:
|
|
32
|
+
return f"Not sent: {stop.decision.message}"
|
|
33
|
+
except ApprovalRequired as wait:
|
|
34
|
+
held[to] = wait
|
|
35
|
+
return f"Waiting for approval: {wait.decision.message}"
|
|
36
|
+
|
|
37
|
+
return send_email
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def as_langchain_tool(fn):
|
|
41
|
+
from langchain_core.tools import StructuredTool
|
|
42
|
+
|
|
43
|
+
return StructuredTool.from_function(fn, name="send_email", description=fn.__doc__)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def as_openai_agents_tool(fn):
|
|
47
|
+
from agents import function_tool
|
|
48
|
+
|
|
49
|
+
return function_tool(fn)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
from agentisend import AgentiSend
|
|
54
|
+
|
|
55
|
+
client = AgentiSend()
|
|
56
|
+
tool_fn = make_tool(lambda p: client.send_email({"from": "agent@acme.dev", **p})["id"])
|
|
57
|
+
print(tool_fn("someone@example.com", "Hello", "First message."))
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""agent-email-guard in front of the AgentiSend Python SDK (pip install agentisend agent-email-guard).
|
|
2
|
+
|
|
3
|
+
Two layers: the local guard stops a runaway agent before a request leaves the
|
|
4
|
+
process, and AgentiSend enforces the key's own limits at the API, where the
|
|
5
|
+
agent cannot remove them. Run: AGENTISEND_API_KEY=... python agentisend_sdk.py
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from agent_email_guard import ApprovalRequired, Guard, GuardRefused
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_send(client, guard=None):
|
|
12
|
+
guard = guard or Guard(budgets={"per_hour": 50, "per_day": 500}, rate={"per_minute": 10})
|
|
13
|
+
return guard.wrap(client.send_email)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main():
|
|
17
|
+
from agentisend import AgentiSend
|
|
18
|
+
|
|
19
|
+
send = build_send(AgentiSend()) # reads AGENTISEND_API_KEY
|
|
20
|
+
try:
|
|
21
|
+
result = send({"from": "Acme <billing@acme.dev>", "to": "customer@example.com", "subject": "Your receipt", "text": "Thanks for your order."})
|
|
22
|
+
print("sent", result["id"])
|
|
23
|
+
except GuardRefused as stop:
|
|
24
|
+
print("refused:", stop.decision.reason, "-", stop.decision.message)
|
|
25
|
+
except ApprovalRequired as held:
|
|
26
|
+
print("held for a person:", held.decision.message)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
if __name__ == "__main__":
|
|
30
|
+
main()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""agent-email-guard in front of Amazon SES via boto3 (pip install boto3 agent-email-guard).
|
|
2
|
+
|
|
3
|
+
SES's send_email takes keyword arguments in its own shape, so ``extract``
|
|
4
|
+
maps them to the guard's send. Run with AWS credentials configured.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from agent_email_guard import Guard, GuardRefused
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def ses_to_send(**kwargs):
|
|
11
|
+
dest = kwargs.get("Destination", {})
|
|
12
|
+
body = kwargs.get("Message", {}).get("Body", {})
|
|
13
|
+
return {
|
|
14
|
+
"to": dest.get("ToAddresses", []),
|
|
15
|
+
"cc": dest.get("CcAddresses", []),
|
|
16
|
+
"bcc": dest.get("BccAddresses", []),
|
|
17
|
+
"subject": kwargs.get("Message", {}).get("Subject", {}).get("Data", ""),
|
|
18
|
+
"text": body.get("Text", {}).get("Data"),
|
|
19
|
+
"html": body.get("Html", {}).get("Data"),
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_send(ses_client, guard=None):
|
|
24
|
+
guard = guard or Guard(budgets={"per_day": 1000}, rate={"per_minute": 14})
|
|
25
|
+
return guard.wrap(ses_client.send_email, extract=ses_to_send)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main():
|
|
29
|
+
import boto3
|
|
30
|
+
|
|
31
|
+
send = build_send(boto3.client("ses", region_name="us-east-1"))
|
|
32
|
+
try:
|
|
33
|
+
send(
|
|
34
|
+
Source="billing@acme.dev",
|
|
35
|
+
Destination={"ToAddresses": ["customer@example.com"]},
|
|
36
|
+
Message={"Subject": {"Data": "Your invoice"}, "Body": {"Text": {"Data": "Invoice attached."}}},
|
|
37
|
+
)
|
|
38
|
+
except GuardRefused as stop:
|
|
39
|
+
print(stop.decision.message)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
if __name__ == "__main__":
|
|
43
|
+
main()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""agent-email-guard in front of the Resend Python SDK (pip install resend agent-email-guard).
|
|
2
|
+
|
|
3
|
+
resend.Emails.send(params) keeps its signature; the guard checks first and
|
|
4
|
+
records only what Resend accepted. Run: RESEND_API_KEY=... python resend_sdk.py
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from agent_email_guard import Guard, GuardRefused
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_send(emails_api, guard=None):
|
|
11
|
+
guard = guard or Guard(budgets={"per_day": 500}, rate={"per_minute": 10}, approval={"recipients": ["@bigcustomer.com"]})
|
|
12
|
+
return guard.wrap(emails_api.send)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main():
|
|
16
|
+
import os
|
|
17
|
+
|
|
18
|
+
import resend
|
|
19
|
+
|
|
20
|
+
resend.api_key = os.environ["RESEND_API_KEY"]
|
|
21
|
+
send = build_send(resend.Emails)
|
|
22
|
+
try:
|
|
23
|
+
print(send({"from": "Acme <onboarding@acme.dev>", "to": ["new-user@example.com"], "subject": "Welcome", "html": "<p>Hi!</p>"}))
|
|
24
|
+
except GuardRefused as stop:
|
|
25
|
+
print(stop.decision.reason, stop.decision.message)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
if __name__ == "__main__":
|
|
29
|
+
main()
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""agent-email-guard in front of plain SMTP with the standard library (pip install agent-email-guard).
|
|
2
|
+
|
|
3
|
+
Works with any SMTP server. Run: SMTP_HOST=... SMTP_USER=... SMTP_PASSWORD=... python smtp_stdlib.py
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import smtplib
|
|
8
|
+
from email.message import EmailMessage
|
|
9
|
+
|
|
10
|
+
from agent_email_guard import Guard, GuardRefused
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def message_to_send(msg):
|
|
14
|
+
return {
|
|
15
|
+
"to": msg.get_all("To", []),
|
|
16
|
+
"cc": msg.get_all("Cc", []),
|
|
17
|
+
"subject": msg.get("Subject", ""),
|
|
18
|
+
"text": msg.get_content() if not msg.is_multipart() else "",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_send(smtp_factory, guard=None):
|
|
23
|
+
guard = guard or Guard(budgets={"per_hour": 100}, rate={"per_minute": 20})
|
|
24
|
+
|
|
25
|
+
def deliver(msg):
|
|
26
|
+
with smtp_factory() as smtp:
|
|
27
|
+
smtp.send_message(msg)
|
|
28
|
+
|
|
29
|
+
return guard.wrap(deliver, extract=message_to_send)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main():
|
|
33
|
+
def factory():
|
|
34
|
+
smtp = smtplib.SMTP(os.environ["SMTP_HOST"], int(os.environ.get("SMTP_PORT", "587")))
|
|
35
|
+
smtp.starttls()
|
|
36
|
+
smtp.login(os.environ["SMTP_USER"], os.environ["SMTP_PASSWORD"])
|
|
37
|
+
return smtp
|
|
38
|
+
|
|
39
|
+
msg = EmailMessage()
|
|
40
|
+
msg["From"], msg["To"], msg["Subject"] = "alerts@acme.dev", "oncall@acme.dev", "Disk at 91%"
|
|
41
|
+
msg.set_content("db-1 crossed 90% disk.")
|
|
42
|
+
try:
|
|
43
|
+
build_send(factory)(msg)
|
|
44
|
+
except GuardRefused as stop:
|
|
45
|
+
print(stop.decision.message)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
if __name__ == "__main__":
|
|
49
|
+
main()
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.18"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "agent-email-guard"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Blast-radius control for agents that send email: budgets, a rate ceiling, loop detection, approval holds and a kill switch, in front of any provider."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "AgentiSend", email = "hello@agentisend.com" }]
|
|
14
|
+
keywords = ["ai-agents", "email", "guardrails", "rate-limit", "mcp", "resend-alternative", "budget", "loop-detection", "kill-switch"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Topic :: Communications :: Email",
|
|
27
|
+
"Typing :: Typed",
|
|
28
|
+
]
|
|
29
|
+
dependencies = []
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
redis = ["redis>=4.2"]
|
|
33
|
+
postgres = ["psycopg>=3.1"]
|
|
34
|
+
dev = ["pytest>=7", "fakeredis[lua]>=2.20"]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://agentisend.com"
|
|
38
|
+
Source = "https://github.com/agentisend/agent-email-guard"
|
|
39
|
+
Issues = "https://github.com/agentisend/agent-email-guard/issues"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
packages = ["src/agent_email_guard"]
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.targets.sdist]
|
|
45
|
+
include = ["src/agent_email_guard", "tests", "examples", "README.md", "LICENSE", "pyproject.toml"]
|
|
46
|
+
|
|
47
|
+
[tool.pytest.ini_options]
|
|
48
|
+
testpaths = ["tests"]
|