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.
@@ -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,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ dist/
4
+ build/
5
+ *.egg-info/
6
+ .venv/
7
+ .pytest_cache/
@@ -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 == []