screwtape-pay-safely 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.
- screwtape_pay_safely-0.1.0/.github/workflows/publish.yml +30 -0
- screwtape_pay_safely-0.1.0/.github/workflows/test.yml +15 -0
- screwtape_pay_safely-0.1.0/.gitignore +7 -0
- screwtape_pay_safely-0.1.0/LICENSE +21 -0
- screwtape_pay_safely-0.1.0/PKG-INFO +165 -0
- screwtape_pay_safely-0.1.0/README.md +138 -0
- screwtape_pay_safely-0.1.0/examples/langgraph_buyer.py +20 -0
- screwtape_pay_safely-0.1.0/pyproject.toml +37 -0
- screwtape_pay_safely-0.1.0/src/pay_safely/__init__.py +21 -0
- screwtape_pay_safely-0.1.0/src/pay_safely/_util.py +20 -0
- screwtape_pay_safely-0.1.0/src/pay_safely/agentkit.py +48 -0
- screwtape_pay_safely-0.1.0/src/pay_safely/core.py +247 -0
- screwtape_pay_safely-0.1.0/src/pay_safely/crewai.py +38 -0
- screwtape_pay_safely-0.1.0/src/pay_safely/langchain.py +66 -0
- screwtape_pay_safely-0.1.0/src/pay_safely/openai_agents.py +55 -0
- screwtape_pay_safely-0.1.0/tests/test_adapters.py +70 -0
- screwtape_pay_safely-0.1.0/tests/test_core.py +73 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
name: publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
publish:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
environment: pypi
|
|
12
|
+
permissions:
|
|
13
|
+
id-token: write
|
|
14
|
+
contents: read
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
|
|
18
|
+
- name: Set up Python
|
|
19
|
+
uses: actions/setup-python@v5
|
|
20
|
+
with:
|
|
21
|
+
python-version: "3.12"
|
|
22
|
+
|
|
23
|
+
- name: Install build
|
|
24
|
+
run: python -m pip install --upgrade pip build
|
|
25
|
+
|
|
26
|
+
- name: Build package
|
|
27
|
+
run: python -m build
|
|
28
|
+
|
|
29
|
+
- name: Publish to PyPI
|
|
30
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
name: test
|
|
2
|
+
on: [push, pull_request]
|
|
3
|
+
jobs:
|
|
4
|
+
test:
|
|
5
|
+
runs-on: ubuntu-latest
|
|
6
|
+
strategy:
|
|
7
|
+
matrix:
|
|
8
|
+
python-version: ["3.10", "3.12"]
|
|
9
|
+
steps:
|
|
10
|
+
- uses: actions/checkout@v4
|
|
11
|
+
- uses: actions/setup-python@v5
|
|
12
|
+
with:
|
|
13
|
+
python-version: ${{ matrix.python-version }}
|
|
14
|
+
- run: pip install -e ".[test,langchain]"
|
|
15
|
+
- run: pytest -q
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Screwtape.ai
|
|
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,165 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: screwtape-pay-safely
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Check that a seller is safe to pay before your AI agent pays. Drop-in for LangChain, LangGraph, CrewAI, OpenAI Agents and AgentKit.
|
|
5
|
+
Project-URL: Homepage, https://screwtape.ai
|
|
6
|
+
Project-URL: Source, https://github.com/screwtapeai/pay-safely
|
|
7
|
+
Project-URL: Documentation, https://screwtape.ai/docs/
|
|
8
|
+
Author-email: "Screwtape.ai" <support@screwtape.ai>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agent-safety,agentkit,ai-agents,crewai,guardrails,langchain,langgraph,openai-agents,payments
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Provides-Extra: agentkit
|
|
17
|
+
Requires-Dist: coinbase-agentkit>=0.5; extra == 'agentkit'
|
|
18
|
+
Provides-Extra: crewai
|
|
19
|
+
Requires-Dist: crewai>=0.100; extra == 'crewai'
|
|
20
|
+
Provides-Extra: langchain
|
|
21
|
+
Requires-Dist: langchain-core>=0.3; extra == 'langchain'
|
|
22
|
+
Provides-Extra: openai-agents
|
|
23
|
+
Requires-Dist: openai-agents>=0.1; extra == 'openai-agents'
|
|
24
|
+
Provides-Extra: test
|
|
25
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# pay-safely
|
|
29
|
+
|
|
30
|
+
Check that a seller is safe to pay **before your AI agent pays**.
|
|
31
|
+
|
|
32
|
+
`pay-safely` asks [Screwtape.ai](https://screwtape.ai) one question at the moment your agent is about to pay: *is this seller safe to pay?* You get back **allow**, **review** or **block**, with reasons and a signed receipt you can verify later.
|
|
33
|
+
|
|
34
|
+
It has no required dependencies and drops into LangChain, LangGraph, CrewAI, the OpenAI Agents SDK and AgentKit.
|
|
35
|
+
|
|
36
|
+
## What the check looks at
|
|
37
|
+
|
|
38
|
+
- The price and payee the seller asks for, compared with what the seller has published.
|
|
39
|
+
- The payee against a denylist and a sanctions list.
|
|
40
|
+
- Whether the amount is far above what your agent intended to pay.
|
|
41
|
+
- Whether the same payment was already made.
|
|
42
|
+
- Whether the seller URL answers with a valid payment request.
|
|
43
|
+
|
|
44
|
+
If the check cannot confirm something, it says **review**, not **allow**. If it cannot run at all (network error, timeout), this package stops the payment by default.
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install screwtape-pay-safely
|
|
50
|
+
# plus the extra for your framework:
|
|
51
|
+
pip install "screwtape-pay-safely[langchain]" # LangChain and LangGraph
|
|
52
|
+
pip install "screwtape-pay-safely[crewai]"
|
|
53
|
+
pip install "screwtape-pay-safely[openai-agents]"
|
|
54
|
+
pip install "screwtape-pay-safely[agentkit]"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Use it in plain Python
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from pay_safely import PaySafely, PaymentBlocked, NeedsReview
|
|
61
|
+
|
|
62
|
+
guard = PaySafely(agent_id="my-agent") # 3 free checks a day, no key
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
verdict = guard.require_safe("https://seller.example/pay", amount=0.05)
|
|
66
|
+
except PaymentBlocked as err:
|
|
67
|
+
print("Do not pay:", err)
|
|
68
|
+
except NeedsReview as err:
|
|
69
|
+
print("Ask a person first:", err)
|
|
70
|
+
else:
|
|
71
|
+
print("OK to pay. Receipt:", verdict.receipt_id)
|
|
72
|
+
# ... make your payment here ...
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Or wrap your payment function:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
@guard.protect(seller_url=lambda url, usd: url, amount=lambda url, usd: usd)
|
|
79
|
+
def pay(url, usd):
|
|
80
|
+
...
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## LangChain and LangGraph
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from pay_safely import PaySafely
|
|
87
|
+
from pay_safely.langchain import seller_check_tool, guard_tool
|
|
88
|
+
|
|
89
|
+
guard = PaySafely(agent_id="my-agent")
|
|
90
|
+
tools = [
|
|
91
|
+
seller_check_tool(guard), # the agent can ask "is this seller safe?"
|
|
92
|
+
guard_tool(my_payment_tool, guard, seller_url_arg="url", amount_arg="usd"), # runs only on allow
|
|
93
|
+
]
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## CrewAI
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from pay_safely import PaySafely
|
|
100
|
+
from pay_safely.crewai import SellerCheckTool
|
|
101
|
+
|
|
102
|
+
buyer = Agent(role="Buyer", goal="Buy safely", backstory="...", tools=[SellerCheckTool(guard=PaySafely(agent_id="buyer"))])
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## OpenAI Agents SDK
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from agents import Agent, function_tool
|
|
109
|
+
from pay_safely import PaySafely
|
|
110
|
+
from pay_safely.openai_agents import seller_check_tool, payment_guardrail
|
|
111
|
+
|
|
112
|
+
guard = PaySafely(agent_id="buyer")
|
|
113
|
+
pay = function_tool(pay_seller, tool_input_guardrails=[payment_guardrail(guard, "url", "usd")])
|
|
114
|
+
agent = Agent(name="Buyer", tools=[seller_check_tool(guard), pay])
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## AgentKit
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from pay_safely import PaySafely
|
|
121
|
+
from pay_safely.agentkit import pay_safely_action_provider
|
|
122
|
+
|
|
123
|
+
# add to the action_providers list in your AgentKit config
|
|
124
|
+
provider = pay_safely_action_provider(PaySafely(agent_id="buyer"))
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Free checks, keys and paid use
|
|
128
|
+
|
|
129
|
+
| | Checks |
|
|
130
|
+
|---|---|
|
|
131
|
+
| No key | 3 free a day |
|
|
132
|
+
| Free API key | 10 free a day. Get one at <https://screwtape.ai/v1/signup> and pass `PaySafely(api_key="...")` |
|
|
133
|
+
| After that | $0.005 per check. See <https://screwtape.ai/llms.txt> for how an agent pays per call. Pass your own payment-capable HTTP client with `transport=` |
|
|
134
|
+
|
|
135
|
+
When the free checks run out, `check()` raises `DailyLimitReached` with the link to get a free key.
|
|
136
|
+
|
|
137
|
+
## Options
|
|
138
|
+
|
|
139
|
+
| Option | Default | Meaning |
|
|
140
|
+
|---|---|---|
|
|
141
|
+
| `fail_open` | `False` | If `True`, a check that cannot run lets the payment go ahead. |
|
|
142
|
+
| `allow_review` | `False` | If `True`, a **review** verdict does not stop the payment. |
|
|
143
|
+
| `timeout` | `10.0` | Seconds to wait for the check. |
|
|
144
|
+
| `transport` | built in | Your own function to send the request. |
|
|
145
|
+
|
|
146
|
+
## Limits
|
|
147
|
+
|
|
148
|
+
This is a second opinion, not a guarantee. The check advises and records. Your code decides whether to pay, and the check cannot stop a payment your code makes anyway. It does not hold funds.
|
|
149
|
+
|
|
150
|
+
## Verify a receipt
|
|
151
|
+
|
|
152
|
+
Every answer includes a `receipt_id` and `verify_url`. Verify it for free at <https://screwtape.ai/verify/>.
|
|
153
|
+
|
|
154
|
+
## Develop
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
pip install -e ".[test]"
|
|
158
|
+
pytest
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Support
|
|
162
|
+
|
|
163
|
+
support@screwtape.ai · <https://screwtape.ai>
|
|
164
|
+
|
|
165
|
+
MIT license.
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# pay-safely
|
|
2
|
+
|
|
3
|
+
Check that a seller is safe to pay **before your AI agent pays**.
|
|
4
|
+
|
|
5
|
+
`pay-safely` asks [Screwtape.ai](https://screwtape.ai) one question at the moment your agent is about to pay: *is this seller safe to pay?* You get back **allow**, **review** or **block**, with reasons and a signed receipt you can verify later.
|
|
6
|
+
|
|
7
|
+
It has no required dependencies and drops into LangChain, LangGraph, CrewAI, the OpenAI Agents SDK and AgentKit.
|
|
8
|
+
|
|
9
|
+
## What the check looks at
|
|
10
|
+
|
|
11
|
+
- The price and payee the seller asks for, compared with what the seller has published.
|
|
12
|
+
- The payee against a denylist and a sanctions list.
|
|
13
|
+
- Whether the amount is far above what your agent intended to pay.
|
|
14
|
+
- Whether the same payment was already made.
|
|
15
|
+
- Whether the seller URL answers with a valid payment request.
|
|
16
|
+
|
|
17
|
+
If the check cannot confirm something, it says **review**, not **allow**. If it cannot run at all (network error, timeout), this package stops the payment by default.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install screwtape-pay-safely
|
|
23
|
+
# plus the extra for your framework:
|
|
24
|
+
pip install "screwtape-pay-safely[langchain]" # LangChain and LangGraph
|
|
25
|
+
pip install "screwtape-pay-safely[crewai]"
|
|
26
|
+
pip install "screwtape-pay-safely[openai-agents]"
|
|
27
|
+
pip install "screwtape-pay-safely[agentkit]"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Use it in plain Python
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from pay_safely import PaySafely, PaymentBlocked, NeedsReview
|
|
34
|
+
|
|
35
|
+
guard = PaySafely(agent_id="my-agent") # 3 free checks a day, no key
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
verdict = guard.require_safe("https://seller.example/pay", amount=0.05)
|
|
39
|
+
except PaymentBlocked as err:
|
|
40
|
+
print("Do not pay:", err)
|
|
41
|
+
except NeedsReview as err:
|
|
42
|
+
print("Ask a person first:", err)
|
|
43
|
+
else:
|
|
44
|
+
print("OK to pay. Receipt:", verdict.receipt_id)
|
|
45
|
+
# ... make your payment here ...
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Or wrap your payment function:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
@guard.protect(seller_url=lambda url, usd: url, amount=lambda url, usd: usd)
|
|
52
|
+
def pay(url, usd):
|
|
53
|
+
...
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## LangChain and LangGraph
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from pay_safely import PaySafely
|
|
60
|
+
from pay_safely.langchain import seller_check_tool, guard_tool
|
|
61
|
+
|
|
62
|
+
guard = PaySafely(agent_id="my-agent")
|
|
63
|
+
tools = [
|
|
64
|
+
seller_check_tool(guard), # the agent can ask "is this seller safe?"
|
|
65
|
+
guard_tool(my_payment_tool, guard, seller_url_arg="url", amount_arg="usd"), # runs only on allow
|
|
66
|
+
]
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## CrewAI
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from pay_safely import PaySafely
|
|
73
|
+
from pay_safely.crewai import SellerCheckTool
|
|
74
|
+
|
|
75
|
+
buyer = Agent(role="Buyer", goal="Buy safely", backstory="...", tools=[SellerCheckTool(guard=PaySafely(agent_id="buyer"))])
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## OpenAI Agents SDK
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from agents import Agent, function_tool
|
|
82
|
+
from pay_safely import PaySafely
|
|
83
|
+
from pay_safely.openai_agents import seller_check_tool, payment_guardrail
|
|
84
|
+
|
|
85
|
+
guard = PaySafely(agent_id="buyer")
|
|
86
|
+
pay = function_tool(pay_seller, tool_input_guardrails=[payment_guardrail(guard, "url", "usd")])
|
|
87
|
+
agent = Agent(name="Buyer", tools=[seller_check_tool(guard), pay])
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## AgentKit
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from pay_safely import PaySafely
|
|
94
|
+
from pay_safely.agentkit import pay_safely_action_provider
|
|
95
|
+
|
|
96
|
+
# add to the action_providers list in your AgentKit config
|
|
97
|
+
provider = pay_safely_action_provider(PaySafely(agent_id="buyer"))
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Free checks, keys and paid use
|
|
101
|
+
|
|
102
|
+
| | Checks |
|
|
103
|
+
|---|---|
|
|
104
|
+
| No key | 3 free a day |
|
|
105
|
+
| Free API key | 10 free a day. Get one at <https://screwtape.ai/v1/signup> and pass `PaySafely(api_key="...")` |
|
|
106
|
+
| After that | $0.005 per check. See <https://screwtape.ai/llms.txt> for how an agent pays per call. Pass your own payment-capable HTTP client with `transport=` |
|
|
107
|
+
|
|
108
|
+
When the free checks run out, `check()` raises `DailyLimitReached` with the link to get a free key.
|
|
109
|
+
|
|
110
|
+
## Options
|
|
111
|
+
|
|
112
|
+
| Option | Default | Meaning |
|
|
113
|
+
|---|---|---|
|
|
114
|
+
| `fail_open` | `False` | If `True`, a check that cannot run lets the payment go ahead. |
|
|
115
|
+
| `allow_review` | `False` | If `True`, a **review** verdict does not stop the payment. |
|
|
116
|
+
| `timeout` | `10.0` | Seconds to wait for the check. |
|
|
117
|
+
| `transport` | built in | Your own function to send the request. |
|
|
118
|
+
|
|
119
|
+
## Limits
|
|
120
|
+
|
|
121
|
+
This is a second opinion, not a guarantee. The check advises and records. Your code decides whether to pay, and the check cannot stop a payment your code makes anyway. It does not hold funds.
|
|
122
|
+
|
|
123
|
+
## Verify a receipt
|
|
124
|
+
|
|
125
|
+
Every answer includes a `receipt_id` and `verify_url`. Verify it for free at <https://screwtape.ai/verify/>.
|
|
126
|
+
|
|
127
|
+
## Develop
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
pip install -e ".[test]"
|
|
131
|
+
pytest
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Support
|
|
135
|
+
|
|
136
|
+
support@screwtape.ai · <https://screwtape.ai>
|
|
137
|
+
|
|
138
|
+
MIT license.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""A LangGraph agent that checks a seller before it pays. Replace pay_seller with your real payment call."""
|
|
2
|
+
from langchain_core.tools import tool
|
|
3
|
+
from langgraph.prebuilt import create_react_agent
|
|
4
|
+
|
|
5
|
+
from pay_safely import PaySafely
|
|
6
|
+
from pay_safely.langchain import guard_tool, seller_check_tool
|
|
7
|
+
|
|
8
|
+
guard = PaySafely(agent_id="demo-buyer")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@tool
|
|
12
|
+
def pay_seller(url: str, usd: float) -> str:
|
|
13
|
+
"""Pay a seller the given amount in US dollars."""
|
|
14
|
+
return f"Paid ${usd} to {url}"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
agent = create_react_agent(
|
|
18
|
+
"openai:gpt-4o-mini",
|
|
19
|
+
[seller_check_tool(guard), guard_tool(pay_seller, guard, seller_url_arg="url", amount_arg="usd")],
|
|
20
|
+
)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "screwtape-pay-safely"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Check that a seller is safe to pay before your AI agent pays. Drop-in for LangChain, LangGraph, CrewAI, OpenAI Agents and AgentKit."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Screwtape.ai", email = "support@screwtape.ai" }]
|
|
13
|
+
keywords = ["ai-agents", "agent-safety", "payments", "langchain", "langgraph", "crewai", "openai-agents", "agentkit", "guardrails"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
]
|
|
19
|
+
dependencies = []
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
langchain = ["langchain-core>=0.3"]
|
|
23
|
+
crewai = ["crewai>=0.100"]
|
|
24
|
+
openai-agents = ["openai-agents>=0.1"]
|
|
25
|
+
agentkit = ["coinbase-agentkit>=0.5"]
|
|
26
|
+
test = ["pytest>=8"]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://screwtape.ai"
|
|
30
|
+
Source = "https://github.com/screwtapeai/pay-safely"
|
|
31
|
+
Documentation = "https://screwtape.ai/docs/"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["src/pay_safely"]
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Check that a seller is safe to pay before your AI agent pays."""
|
|
2
|
+
from .core import (
|
|
3
|
+
CheckUnavailable,
|
|
4
|
+
DailyLimitReached,
|
|
5
|
+
NeedsReview,
|
|
6
|
+
PaymentBlocked,
|
|
7
|
+
PaySafely,
|
|
8
|
+
PaySafelyError,
|
|
9
|
+
Verdict,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"PaySafely",
|
|
14
|
+
"Verdict",
|
|
15
|
+
"PaySafelyError",
|
|
16
|
+
"PaymentBlocked",
|
|
17
|
+
"NeedsReview",
|
|
18
|
+
"CheckUnavailable",
|
|
19
|
+
"DailyLimitReached",
|
|
20
|
+
]
|
|
21
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from .core import CheckUnavailable, DailyLimitReached, NeedsReview, PaymentBlocked, PaySafely
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def run_check_text(guard: PaySafely, seller_url: Optional[str], amount: Optional[float]) -> "tuple[bool, str]":
|
|
9
|
+
"""Run the guard and return (ok_to_pay, message the agent can read)."""
|
|
10
|
+
try:
|
|
11
|
+
verdict = guard.require_safe(seller_url=seller_url, amount=amount)
|
|
12
|
+
except PaymentBlocked as err:
|
|
13
|
+
return False, f"Do not pay. {err.verdict.summary()}"
|
|
14
|
+
except NeedsReview as err:
|
|
15
|
+
return False, f"Do not pay yet; ask a person to review. {err.verdict.summary()}"
|
|
16
|
+
except (CheckUnavailable, DailyLimitReached) as err:
|
|
17
|
+
return False, f"Do not pay: the seller check could not run. {err}"
|
|
18
|
+
except ValueError as err:
|
|
19
|
+
return False, f"Do not pay: {err}"
|
|
20
|
+
return True, f"Safe to pay. {verdict.summary()}"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""AgentKit: an action provider with a 'check seller before paying' action.
|
|
2
|
+
|
|
3
|
+
from coinbase_agentkit import AgentKit, AgentKitConfig
|
|
4
|
+
from pay_safely import PaySafely
|
|
5
|
+
from pay_safely.agentkit import pay_safely_action_provider
|
|
6
|
+
|
|
7
|
+
agentkit = AgentKit(AgentKitConfig(..., action_providers=[pay_safely_action_provider(PaySafely(agent_id="buyer"))]))
|
|
8
|
+
|
|
9
|
+
Needs: pip install "screwtape-pay-safely[agentkit]"
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
from coinbase_agentkit import ActionProvider, create_action
|
|
16
|
+
from pydantic import BaseModel, Field
|
|
17
|
+
|
|
18
|
+
from ._util import run_check_text
|
|
19
|
+
from .core import PaySafely
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CheckSellerSchema(BaseModel):
|
|
23
|
+
seller_url: str = Field(description="Public https URL of the seller or paid endpoint you are about to pay.")
|
|
24
|
+
amount_usd: Optional[float] = Field(default=None, description="Amount in US dollars you intend to pay.")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PaySafelyActionProvider(ActionProvider[Any]):
|
|
28
|
+
def __init__(self, guard: Optional[PaySafely] = None):
|
|
29
|
+
super().__init__("pay_safely", [])
|
|
30
|
+
self.guard = guard or PaySafely()
|
|
31
|
+
|
|
32
|
+
@create_action(
|
|
33
|
+
name="check_seller_before_paying",
|
|
34
|
+
description=(
|
|
35
|
+
"Call this BEFORE paying any seller or paid API. Returns ALLOW, REVIEW or BLOCK with reasons and a signed "
|
|
36
|
+
"receipt id. Only pay when the answer starts with 'Safe to pay'."
|
|
37
|
+
),
|
|
38
|
+
schema=CheckSellerSchema,
|
|
39
|
+
)
|
|
40
|
+
def check_seller_before_paying(self, args: dict[str, Any]) -> str:
|
|
41
|
+
return run_check_text(self.guard, args["seller_url"], args.get("amount_usd"))[1]
|
|
42
|
+
|
|
43
|
+
def supports_network(self, network: Any) -> bool:
|
|
44
|
+
return True
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def pay_safely_action_provider(guard: Optional[PaySafely] = None) -> PaySafelyActionProvider:
|
|
48
|
+
return PaySafelyActionProvider(guard)
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Core client: ask Screwtape.ai whether a seller is safe to pay, before your agent pays."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import functools
|
|
5
|
+
import json
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
import uuid
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
DEFAULT_BASE_URL = "https://screwtape.ai"
|
|
13
|
+
CHECK_PATH = "/v1/payment-check"
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"PaySafely",
|
|
17
|
+
"Verdict",
|
|
18
|
+
"PaymentBlocked",
|
|
19
|
+
"NeedsReview",
|
|
20
|
+
"CheckUnavailable",
|
|
21
|
+
"DailyLimitReached",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PaySafelyError(Exception):
|
|
26
|
+
"""Base class for every error raised by this package."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PaymentBlocked(PaySafelyError):
|
|
30
|
+
"""The check said block. Do not pay."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, verdict: "Verdict"):
|
|
33
|
+
self.verdict = verdict
|
|
34
|
+
super().__init__("Payment blocked: " + "; ".join(verdict.reasons or ["seller failed the safety check"]))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class NeedsReview(PaySafelyError):
|
|
38
|
+
"""The check said review. A person should look before the agent pays."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, verdict: "Verdict"):
|
|
41
|
+
self.verdict = verdict
|
|
42
|
+
super().__init__("Payment needs review: " + "; ".join(verdict.reasons or ["the check could not confirm the seller"]))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class CheckUnavailable(PaySafelyError):
|
|
46
|
+
"""The check could not run (network error, timeout or server error). Fails closed by default."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class DailyLimitReached(PaySafelyError):
|
|
50
|
+
"""The free daily calls are used and the request needs payment or an API key."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, message: str, body: Optional[Dict[str, Any]] = None):
|
|
53
|
+
self.body = body or {}
|
|
54
|
+
super().__init__(message)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class Verdict:
|
|
59
|
+
"""What the payment check returned."""
|
|
60
|
+
|
|
61
|
+
verdict: str # "allow", "review" or "block"
|
|
62
|
+
reasons: List[str] = field(default_factory=list)
|
|
63
|
+
payee: Optional[str] = None
|
|
64
|
+
amount_usd: Optional[float] = None
|
|
65
|
+
receipt_id: Optional[str] = None
|
|
66
|
+
verify_url: Optional[str] = None
|
|
67
|
+
raw: Dict[str, Any] = field(default_factory=dict)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def allowed(self) -> bool:
|
|
71
|
+
return self.verdict == "allow"
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def blocked(self) -> bool:
|
|
75
|
+
return self.verdict == "block"
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def needs_review(self) -> bool:
|
|
79
|
+
return self.verdict == "review"
|
|
80
|
+
|
|
81
|
+
def summary(self) -> str:
|
|
82
|
+
why = "; ".join(self.reasons) if self.reasons else "no reasons given"
|
|
83
|
+
rid = f" Receipt: {self.receipt_id}." if self.receipt_id else ""
|
|
84
|
+
return f"{self.verdict.upper()}: {why}.{rid}"
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def from_response(cls, body: Dict[str, Any]) -> "Verdict":
|
|
88
|
+
return cls(
|
|
89
|
+
verdict=str(body.get("verdict", "review")),
|
|
90
|
+
reasons=[str(r) for r in body.get("reasons", []) or []],
|
|
91
|
+
payee=body.get("payee"),
|
|
92
|
+
amount_usd=body.get("amount_usd"),
|
|
93
|
+
receipt_id=body.get("receipt_id"),
|
|
94
|
+
verify_url=body.get("verify_url"),
|
|
95
|
+
raw=body,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# A transport takes (url, json_body, headers, timeout) and returns (status_code, parsed_json_or_text).
|
|
100
|
+
Transport = Callable[[str, Dict[str, Any], Dict[str, str], float], "tuple[int, Any]"]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _urllib_transport(url: str, body: Dict[str, Any], headers: Dict[str, str], timeout: float):
|
|
104
|
+
request = urllib.request.Request(url, data=json.dumps(body).encode("utf-8"), headers=headers, method="POST")
|
|
105
|
+
try:
|
|
106
|
+
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 - https URL, fixed endpoint
|
|
107
|
+
payload = response.read().decode("utf-8")
|
|
108
|
+
status = response.status
|
|
109
|
+
except urllib.error.HTTPError as err:
|
|
110
|
+
payload = err.read().decode("utf-8", "replace")
|
|
111
|
+
status = err.code
|
|
112
|
+
try:
|
|
113
|
+
return status, json.loads(payload)
|
|
114
|
+
except ValueError:
|
|
115
|
+
return status, payload
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class PaySafely:
|
|
119
|
+
"""Ask Screwtape.ai whether a seller is safe to pay.
|
|
120
|
+
|
|
121
|
+
Parameters
|
|
122
|
+
----------
|
|
123
|
+
api_key:
|
|
124
|
+
Optional free API key (10 checks a day). Without a key you get 3 free checks a day.
|
|
125
|
+
agent_id:
|
|
126
|
+
A label for your agent. It shows up on the signed receipt.
|
|
127
|
+
fail_open:
|
|
128
|
+
If True, a failed check (network error, timeout) lets the payment go ahead. Default False:
|
|
129
|
+
if the check cannot run, the payment is not allowed.
|
|
130
|
+
allow_review:
|
|
131
|
+
If True, a "review" verdict is treated as allowed. Default False: "review" stops the payment
|
|
132
|
+
until a person looks.
|
|
133
|
+
transport:
|
|
134
|
+
Advanced. A function that sends the request, so you can use your own HTTP client, for example
|
|
135
|
+
one that can attach a payment when the free checks run out.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def __init__(
|
|
139
|
+
self,
|
|
140
|
+
api_key: Optional[str] = None,
|
|
141
|
+
agent_id: str = "pay-safely",
|
|
142
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
143
|
+
timeout: float = 10.0,
|
|
144
|
+
fail_open: bool = False,
|
|
145
|
+
allow_review: bool = False,
|
|
146
|
+
transport: Optional[Transport] = None,
|
|
147
|
+
):
|
|
148
|
+
self.api_key = api_key
|
|
149
|
+
self.agent_id = agent_id
|
|
150
|
+
self.base_url = base_url.rstrip("/")
|
|
151
|
+
self.timeout = timeout
|
|
152
|
+
self.fail_open = fail_open
|
|
153
|
+
self.allow_review = allow_review
|
|
154
|
+
self._transport = transport or _urllib_transport
|
|
155
|
+
|
|
156
|
+
# -- checking ---------------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
def check(
|
|
159
|
+
self,
|
|
160
|
+
seller_url: Optional[str] = None,
|
|
161
|
+
amount: Optional[float] = None,
|
|
162
|
+
payment_required: Optional[Any] = None,
|
|
163
|
+
) -> Verdict:
|
|
164
|
+
"""Run the check and return the Verdict. Raises CheckUnavailable or DailyLimitReached on failure.
|
|
165
|
+
|
|
166
|
+
seller_url: the public https URL of the seller or paid endpoint you are about to pay.
|
|
167
|
+
amount: the amount in US dollars your agent intends to pay (lets the check flag an over-price).
|
|
168
|
+
payment_required: alternatively, the seller's payment-required response, if you already have it.
|
|
169
|
+
"""
|
|
170
|
+
if not seller_url and payment_required is None:
|
|
171
|
+
raise ValueError("Give seller_url or payment_required.")
|
|
172
|
+
body: Dict[str, Any] = {"agent_id": self.agent_id, "event_id": f"{self.agent_id}-{uuid.uuid4().hex[:16]}"}
|
|
173
|
+
if seller_url:
|
|
174
|
+
body["seller_url"] = seller_url
|
|
175
|
+
if payment_required is not None:
|
|
176
|
+
body["payment_required"] = payment_required
|
|
177
|
+
if amount is not None:
|
|
178
|
+
body["intended_amount"] = float(amount)
|
|
179
|
+
headers = {"content-type": "application/json", "accept": "application/json", "user-agent": "screwtape-pay-safely/0.1"}
|
|
180
|
+
if self.api_key:
|
|
181
|
+
headers["authorization"] = f"Bearer {self.api_key}"
|
|
182
|
+
try:
|
|
183
|
+
status, payload = self._transport(self.base_url + CHECK_PATH, body, headers, self.timeout)
|
|
184
|
+
except PaySafelyError:
|
|
185
|
+
raise
|
|
186
|
+
except Exception as err: # network error, timeout, TLS
|
|
187
|
+
raise CheckUnavailable(f"Could not reach the check: {err.__class__.__name__}") from err
|
|
188
|
+
if status == 200 and isinstance(payload, dict):
|
|
189
|
+
return Verdict.from_response(payload)
|
|
190
|
+
if status == 402:
|
|
191
|
+
detail = payload if isinstance(payload, dict) else {}
|
|
192
|
+
hint = detail.get("next_action") or "The free daily checks are used."
|
|
193
|
+
raise DailyLimitReached(
|
|
194
|
+
f"{hint} Get a free key at {self.base_url}/v1/signup (10 checks a day), or see {self.base_url}/llms.txt for pay-per-call.",
|
|
195
|
+
detail,
|
|
196
|
+
)
|
|
197
|
+
if status == 429:
|
|
198
|
+
raise CheckUnavailable("Rate limited. Wait a moment and retry.")
|
|
199
|
+
if status == 400:
|
|
200
|
+
message = payload.get("message") if isinstance(payload, dict) else str(payload)
|
|
201
|
+
raise ValueError(f"The check rejected the input: {message}")
|
|
202
|
+
raise CheckUnavailable(f"The check returned HTTP {status}.")
|
|
203
|
+
|
|
204
|
+
def require_safe(
|
|
205
|
+
self,
|
|
206
|
+
seller_url: Optional[str] = None,
|
|
207
|
+
amount: Optional[float] = None,
|
|
208
|
+
payment_required: Optional[Any] = None,
|
|
209
|
+
) -> Verdict:
|
|
210
|
+
"""Like check(), but raises PaymentBlocked or NeedsReview unless the seller is allowed.
|
|
211
|
+
|
|
212
|
+
Call this right before your agent pays. If it returns, it is safe to go ahead.
|
|
213
|
+
"""
|
|
214
|
+
try:
|
|
215
|
+
verdict = self.check(seller_url=seller_url, amount=amount, payment_required=payment_required)
|
|
216
|
+
except (CheckUnavailable, DailyLimitReached):
|
|
217
|
+
if self.fail_open:
|
|
218
|
+
return Verdict(verdict="allow", reasons=["The check was unavailable and fail_open is on."])
|
|
219
|
+
raise
|
|
220
|
+
if verdict.blocked:
|
|
221
|
+
raise PaymentBlocked(verdict)
|
|
222
|
+
if verdict.needs_review and not self.allow_review:
|
|
223
|
+
raise NeedsReview(verdict)
|
|
224
|
+
return verdict
|
|
225
|
+
|
|
226
|
+
# -- wrapping ---------------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
def protect(
|
|
229
|
+
self,
|
|
230
|
+
seller_url: Callable[..., Optional[str]],
|
|
231
|
+
amount: Optional[Callable[..., Optional[float]]] = None,
|
|
232
|
+
):
|
|
233
|
+
"""Decorator: run the check on the arguments of a payment function before it runs.
|
|
234
|
+
|
|
235
|
+
@guard.protect(seller_url=lambda url, usd: url, amount=lambda url, usd: usd)
|
|
236
|
+
def pay(url, usd): ...
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
def decorate(func):
|
|
240
|
+
@functools.wraps(func)
|
|
241
|
+
def wrapper(*args, **kwargs):
|
|
242
|
+
self.require_safe(seller_url=seller_url(*args, **kwargs), amount=amount(*args, **kwargs) if amount else None)
|
|
243
|
+
return func(*args, **kwargs)
|
|
244
|
+
|
|
245
|
+
return wrapper
|
|
246
|
+
|
|
247
|
+
return decorate
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""CrewAI: a tool that checks a seller before the crew pays.
|
|
2
|
+
|
|
3
|
+
from pay_safely import PaySafely
|
|
4
|
+
from pay_safely.crewai import SellerCheckTool
|
|
5
|
+
|
|
6
|
+
agent = Agent(role="Buyer", goal="...", backstory="...", tools=[SellerCheckTool(guard=PaySafely(agent_id="buyer"))])
|
|
7
|
+
|
|
8
|
+
Needs: pip install "screwtape-pay-safely[crewai]"
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Optional, Type
|
|
13
|
+
|
|
14
|
+
from crewai.tools import BaseTool
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
16
|
+
|
|
17
|
+
from ._util import run_check_text
|
|
18
|
+
from .core import PaySafely
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SellerCheckInput(BaseModel):
|
|
22
|
+
seller_url: str = Field(..., description="Public https URL of the seller or paid endpoint you are about to pay.")
|
|
23
|
+
amount_usd: Optional[float] = Field(default=None, description="Amount in US dollars you intend to pay.")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SellerCheckTool(BaseTool):
|
|
27
|
+
name: str = "check_seller_before_paying"
|
|
28
|
+
description: str = (
|
|
29
|
+
"Call this BEFORE paying any seller or paid API. Returns ALLOW, REVIEW or BLOCK with reasons and a signed "
|
|
30
|
+
"receipt id. Only pay when the answer starts with 'Safe to pay'."
|
|
31
|
+
)
|
|
32
|
+
args_schema: Type[BaseModel] = SellerCheckInput
|
|
33
|
+
guard: Any = None
|
|
34
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
35
|
+
|
|
36
|
+
def _run(self, seller_url: str, amount_usd: Optional[float] = None) -> str:
|
|
37
|
+
guard = self.guard or PaySafely()
|
|
38
|
+
return run_check_text(guard, seller_url, amount_usd)[1]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""LangChain and LangGraph: a tool that checks a seller, and a wrapper that checks before a payment tool runs.
|
|
2
|
+
|
|
3
|
+
from pay_safely import PaySafely
|
|
4
|
+
from pay_safely.langchain import seller_check_tool, guard_tool
|
|
5
|
+
|
|
6
|
+
guard = PaySafely(agent_id="my-agent")
|
|
7
|
+
tools = [seller_check_tool(guard), guard_tool(my_payment_tool, guard, seller_url_arg="url", amount_arg="usd")]
|
|
8
|
+
|
|
9
|
+
Works with `create_react_agent` (LangGraph) and with any LangChain agent that takes tools.
|
|
10
|
+
Needs: pip install "screwtape-pay-safely[langchain]"
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from langchain_core.tools import BaseTool, StructuredTool
|
|
17
|
+
from pydantic import BaseModel, Field
|
|
18
|
+
|
|
19
|
+
from ._util import run_check_text
|
|
20
|
+
from .core import PaySafely
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _SellerCheckInput(BaseModel):
|
|
24
|
+
seller_url: str = Field(description="Public https URL of the seller or paid endpoint you are about to pay.")
|
|
25
|
+
amount_usd: Optional[float] = Field(default=None, description="Amount in US dollars you intend to pay.")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def seller_check_tool(guard: PaySafely) -> StructuredTool:
|
|
29
|
+
"""A tool the agent can call: 'is this seller safe to pay?'"""
|
|
30
|
+
|
|
31
|
+
def check_seller_before_paying(seller_url: str, amount_usd: Optional[float] = None) -> str:
|
|
32
|
+
return run_check_text(guard, seller_url, amount_usd)[1]
|
|
33
|
+
|
|
34
|
+
return StructuredTool.from_function(
|
|
35
|
+
func=check_seller_before_paying,
|
|
36
|
+
name="check_seller_before_paying",
|
|
37
|
+
description=(
|
|
38
|
+
"Call this BEFORE paying any seller or paid API. Returns ALLOW, REVIEW or BLOCK with reasons and a signed "
|
|
39
|
+
"receipt id. Only pay when the answer starts with 'Safe to pay'."
|
|
40
|
+
),
|
|
41
|
+
args_schema=_SellerCheckInput,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def guard_tool(tool: BaseTool, guard: PaySafely, seller_url_arg: str, amount_arg: Optional[str] = None) -> StructuredTool:
|
|
46
|
+
"""Wrap a payment tool so the seller check runs first. The payment tool only runs on an allow verdict.
|
|
47
|
+
|
|
48
|
+
seller_url_arg: name of the tool argument that holds the seller URL.
|
|
49
|
+
amount_arg: name of the tool argument that holds the amount in dollars (optional).
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def guarded(**kwargs):
|
|
53
|
+
url = kwargs.get(seller_url_arg)
|
|
54
|
+
amount = kwargs.get(amount_arg) if amount_arg else None
|
|
55
|
+
ok, message = run_check_text(guard, url, float(amount) if amount is not None else None)
|
|
56
|
+
if not ok:
|
|
57
|
+
return f"Payment not made. {message}"
|
|
58
|
+
return tool.invoke(kwargs)
|
|
59
|
+
|
|
60
|
+
return StructuredTool.from_function(
|
|
61
|
+
func=guarded,
|
|
62
|
+
name=tool.name,
|
|
63
|
+
description=tool.description,
|
|
64
|
+
args_schema=tool.args_schema,
|
|
65
|
+
infer_schema=False,
|
|
66
|
+
)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""OpenAI Agents SDK: a check tool, and a guardrail that stops a payment tool unless the seller is allowed.
|
|
2
|
+
|
|
3
|
+
from agents import Agent
|
|
4
|
+
from pay_safely import PaySafely
|
|
5
|
+
from pay_safely.openai_agents import seller_check_tool, payment_guardrail
|
|
6
|
+
|
|
7
|
+
guard = PaySafely(agent_id="buyer")
|
|
8
|
+
pay = function_tool(pay_seller, tool_input_guardrails=[payment_guardrail(guard, seller_url_arg="url", amount_arg="usd")])
|
|
9
|
+
agent = Agent(name="Buyer", tools=[seller_check_tool(guard), pay])
|
|
10
|
+
|
|
11
|
+
Needs: pip install "screwtape-pay-safely[openai-agents]"
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
from agents import ToolGuardrailFunctionOutput, ToolInputGuardrail, ToolInputGuardrailData, function_tool
|
|
19
|
+
|
|
20
|
+
from ._util import run_check_text
|
|
21
|
+
from .core import PaySafely
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def seller_check_tool(guard: PaySafely):
|
|
25
|
+
"""A function tool the agent can call: 'is this seller safe to pay?'"""
|
|
26
|
+
|
|
27
|
+
@function_tool(name_override="check_seller_before_paying")
|
|
28
|
+
def check_seller_before_paying(seller_url: str, amount_usd: Optional[float] = None) -> str:
|
|
29
|
+
"""Call this BEFORE paying any seller or paid API. Returns ALLOW, REVIEW or BLOCK with reasons and a signed
|
|
30
|
+
receipt id. Only pay when the answer starts with 'Safe to pay'.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
seller_url: Public https URL of the seller or paid endpoint you are about to pay.
|
|
34
|
+
amount_usd: Amount in US dollars you intend to pay.
|
|
35
|
+
"""
|
|
36
|
+
return run_check_text(guard, seller_url, amount_usd)[1]
|
|
37
|
+
|
|
38
|
+
return check_seller_before_paying
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def payment_guardrail(guard: PaySafely, seller_url_arg: str, amount_arg: Optional[str] = None) -> ToolInputGuardrail:
|
|
42
|
+
"""A tool input guardrail. Put it on your payment tool; the tool runs only when the seller check allows it."""
|
|
43
|
+
|
|
44
|
+
def run(data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput:
|
|
45
|
+
try:
|
|
46
|
+
args = json.loads(data.context.tool_arguments or "{}")
|
|
47
|
+
except ValueError:
|
|
48
|
+
args = {}
|
|
49
|
+
amount = args.get(amount_arg) if amount_arg else None
|
|
50
|
+
ok, message = run_check_text(guard, args.get(seller_url_arg), float(amount) if amount is not None else None)
|
|
51
|
+
if ok:
|
|
52
|
+
return ToolGuardrailFunctionOutput.allow(output_info=message)
|
|
53
|
+
return ToolGuardrailFunctionOutput.reject_content(f"Payment not made. {message}", output_info=message)
|
|
54
|
+
|
|
55
|
+
return ToolInputGuardrail(guardrail_function=run, name="pay_safely_seller_check")
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from pay_safely import PaySafely
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def guard_with(verdict):
|
|
9
|
+
return PaySafely(transport=lambda url, body, headers, timeout: (200, {"verdict": verdict, "reasons": ["r"], "receipt_id": "rid"}))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_langchain_guard_tool_blocks_and_allows():
|
|
13
|
+
pytest.importorskip("langchain_core")
|
|
14
|
+
from langchain_core.tools import StructuredTool
|
|
15
|
+
|
|
16
|
+
from pay_safely.langchain import guard_tool, seller_check_tool
|
|
17
|
+
|
|
18
|
+
calls = []
|
|
19
|
+
|
|
20
|
+
def pay(url: str, usd: float) -> str:
|
|
21
|
+
calls.append(url)
|
|
22
|
+
return "paid"
|
|
23
|
+
|
|
24
|
+
base = StructuredTool.from_function(pay, name="pay", description="Pay a seller")
|
|
25
|
+
blocked = guard_tool(base, guard_with("block"), "url", "usd")
|
|
26
|
+
assert "Payment not made" in blocked.invoke({"url": "https://seller.example/p", "usd": 1})
|
|
27
|
+
assert calls == []
|
|
28
|
+
allowed = guard_tool(base, guard_with("allow"), "url", "usd")
|
|
29
|
+
assert allowed.invoke({"url": "https://seller.example/p", "usd": 1}) == "paid"
|
|
30
|
+
assert calls == ["https://seller.example/p"]
|
|
31
|
+
assert seller_check_tool(guard_with("allow")).invoke({"seller_url": "https://seller.example/p"}).startswith("Safe to pay")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_openai_agents_tool_and_guardrail():
|
|
35
|
+
pytest.importorskip("agents")
|
|
36
|
+
from pay_safely.openai_agents import payment_guardrail, seller_check_tool
|
|
37
|
+
|
|
38
|
+
tool = seller_check_tool(guard_with("allow"))
|
|
39
|
+
assert tool.name == "check_seller_before_paying"
|
|
40
|
+
rail = payment_guardrail(guard_with("block"), "url", "usd")
|
|
41
|
+
|
|
42
|
+
class Ctx:
|
|
43
|
+
tool_arguments = json.dumps({"url": "https://seller.example/p", "usd": 2})
|
|
44
|
+
|
|
45
|
+
class Data:
|
|
46
|
+
context = Ctx()
|
|
47
|
+
agent = None
|
|
48
|
+
|
|
49
|
+
import asyncio
|
|
50
|
+
|
|
51
|
+
result = asyncio.run(rail.run(Data())) if hasattr(rail, "run") else rail.guardrail_function(Data())
|
|
52
|
+
assert result.behavior["type"] == "reject_content"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_crewai_tool():
|
|
56
|
+
pytest.importorskip("crewai")
|
|
57
|
+
from pay_safely.crewai import SellerCheckTool
|
|
58
|
+
|
|
59
|
+
tool = SellerCheckTool(guard=guard_with("block"))
|
|
60
|
+
assert tool._run("https://seller.example/p", 1.0).startswith("Do not pay")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_agentkit_action_provider():
|
|
64
|
+
pytest.importorskip("coinbase_agentkit")
|
|
65
|
+
from pay_safely.agentkit import pay_safely_action_provider
|
|
66
|
+
|
|
67
|
+
provider = pay_safely_action_provider(guard_with("allow"))
|
|
68
|
+
actions = provider.get_actions(None)
|
|
69
|
+
assert [a.name for a in actions] == ["pay_safely_check_seller_before_paying"] or actions[0].name.endswith("check_seller_before_paying")
|
|
70
|
+
assert actions[0].invoke({"seller_url": "https://seller.example/p"}).startswith("Safe to pay")
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from pay_safely import CheckUnavailable, DailyLimitReached, NeedsReview, PaymentBlocked, PaySafely
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def fake(status, payload, seen=None):
|
|
7
|
+
def transport(url, body, headers, timeout):
|
|
8
|
+
if seen is not None:
|
|
9
|
+
seen.append((url, body, headers))
|
|
10
|
+
return status, payload
|
|
11
|
+
|
|
12
|
+
return transport
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_allow_returns_verdict_and_sends_expected_request():
|
|
16
|
+
seen = []
|
|
17
|
+
guard = PaySafely(api_key="k", agent_id="a1", transport=fake(200, {"verdict": "allow", "reasons": ["ok"], "receipt_id": "r1"}, seen))
|
|
18
|
+
verdict = guard.require_safe("https://seller.example/pay", amount=0.05)
|
|
19
|
+
assert verdict.allowed and verdict.receipt_id == "r1"
|
|
20
|
+
url, body, headers = seen[0]
|
|
21
|
+
assert url == "https://screwtape.ai/v1/payment-check"
|
|
22
|
+
assert body["seller_url"] == "https://seller.example/pay" and body["intended_amount"] == 0.05 and body["agent_id"] == "a1"
|
|
23
|
+
assert headers["authorization"] == "Bearer k"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_block_raises():
|
|
27
|
+
guard = PaySafely(transport=fake(200, {"verdict": "block", "reasons": ["payee is sanctioned"]}))
|
|
28
|
+
with pytest.raises(PaymentBlocked) as err:
|
|
29
|
+
guard.require_safe("https://seller.example/pay")
|
|
30
|
+
assert "sanctioned" in str(err.value)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_review_stops_unless_allowed():
|
|
34
|
+
payload = {"verdict": "review", "reasons": ["could not fetch"]}
|
|
35
|
+
with pytest.raises(NeedsReview):
|
|
36
|
+
PaySafely(transport=fake(200, payload)).require_safe("https://seller.example/pay")
|
|
37
|
+
assert PaySafely(transport=fake(200, payload), allow_review=True).require_safe("https://seller.example/pay").needs_review
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_fails_closed_by_default_and_open_on_request():
|
|
41
|
+
def boom(*a):
|
|
42
|
+
raise TimeoutError("slow")
|
|
43
|
+
|
|
44
|
+
with pytest.raises(CheckUnavailable):
|
|
45
|
+
PaySafely(transport=boom).require_safe("https://seller.example/pay")
|
|
46
|
+
assert PaySafely(transport=boom, fail_open=True).require_safe("https://seller.example/pay").allowed
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_daily_limit_message_points_to_free_key():
|
|
50
|
+
guard = PaySafely(transport=fake(402, {"next_action": "Free calls used."}))
|
|
51
|
+
with pytest.raises(DailyLimitReached) as err:
|
|
52
|
+
guard.check("https://seller.example/pay")
|
|
53
|
+
assert "signup" in str(err.value)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_bad_input():
|
|
57
|
+
with pytest.raises(ValueError):
|
|
58
|
+
PaySafely(transport=fake(200, {})).check()
|
|
59
|
+
with pytest.raises(ValueError):
|
|
60
|
+
PaySafely(transport=fake(400, {"message": "seller_url must be a public https URL."})).check("http://x")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_protect_decorator_blocks_payment_function():
|
|
64
|
+
paid = []
|
|
65
|
+
guard = PaySafely(transport=fake(200, {"verdict": "block", "reasons": ["no"]}))
|
|
66
|
+
|
|
67
|
+
@guard.protect(seller_url=lambda url, usd: url, amount=lambda url, usd: usd)
|
|
68
|
+
def pay(url, usd):
|
|
69
|
+
paid.append(url)
|
|
70
|
+
|
|
71
|
+
with pytest.raises(PaymentBlocked):
|
|
72
|
+
pay("https://seller.example/pay", 1.0)
|
|
73
|
+
assert paid == []
|