agentvalet 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.
- agentvalet-0.1.0/.gitignore +8 -0
- agentvalet-0.1.0/PKG-INFO +194 -0
- agentvalet-0.1.0/README.md +162 -0
- agentvalet-0.1.0/pyproject.toml +74 -0
- agentvalet-0.1.0/src/agentvalet/__init__.py +55 -0
- agentvalet-0.1.0/src/agentvalet/_constants.py +31 -0
- agentvalet-0.1.0/src/agentvalet/_protocol.py +168 -0
- agentvalet-0.1.0/src/agentvalet/_signing.py +33 -0
- agentvalet-0.1.0/src/agentvalet/aio.py +267 -0
- agentvalet-0.1.0/src/agentvalet/client.py +315 -0
- agentvalet-0.1.0/src/agentvalet/env.py +109 -0
- agentvalet-0.1.0/src/agentvalet/errors.py +111 -0
- agentvalet-0.1.0/src/agentvalet/py.typed +0 -0
- agentvalet-0.1.0/src/agentvalet/register.py +183 -0
- agentvalet-0.1.0/tests/conftest.py +69 -0
- agentvalet-0.1.0/tests/test_client.py +368 -0
- agentvalet-0.1.0/tests/test_constants.py +31 -0
- agentvalet-0.1.0/tests/test_env.py +84 -0
- agentvalet-0.1.0/tests/test_register.py +120 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: agentvalet
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Call approved SaaS platforms from any Python agent. Your agent never holds the downstream credential.
|
|
5
|
+
Project-URL: Homepage, https://agentvalet.ai
|
|
6
|
+
Project-URL: Documentation, https://docs.agentvalet.ai
|
|
7
|
+
Project-URL: Source, https://github.com/MCSEdwin/agentvalet
|
|
8
|
+
Author: AgentValet
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: agents,agentvalet,ai,authorization,credentials,mcp,sdk
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Security
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Requires-Dist: httpx>=0.24
|
|
24
|
+
Requires-Dist: pyjwt[crypto]>=2.6
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=7.4; extra == 'dev'
|
|
29
|
+
Requires-Dist: respx>=0.20; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# agentvalet
|
|
34
|
+
|
|
35
|
+
Call approved SaaS platforms from any Python agent. Your agent never holds the
|
|
36
|
+
downstream credential — AgentValet signs a short-lived identity assertion,
|
|
37
|
+
checks the call against the owner's grants and policy, injects the credential at
|
|
38
|
+
call time, and writes an audit record.
|
|
39
|
+
|
|
40
|
+
This is the Python port of
|
|
41
|
+
[`@agentvalet/client`](https://www.npmjs.com/package/@agentvalet/client). Same
|
|
42
|
+
endpoints, same approval semantics, same timings.
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install agentvalet
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Python 3.9+. Two dependencies: `httpx` and `pyjwt[crypto]`.
|
|
51
|
+
|
|
52
|
+
## Get an agent identity
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
agentvalet register --code <invite-or-enrollment-code>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The RSA keypair is generated **on your machine**. Only the public half is sent;
|
|
59
|
+
the private key is written to `~/.agentvalet/agent.key` (mode 0600) and never
|
|
60
|
+
crosses the wire. The code is either an invite's bind secret or the enrollment
|
|
61
|
+
code from the "Try it free" flow.
|
|
62
|
+
|
|
63
|
+
This writes the same files as `npx @agentvalet/register`, so the Node and Python
|
|
64
|
+
tooling are interchangeable — you do not need Node installed.
|
|
65
|
+
|
|
66
|
+
## Use it
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from agentvalet import AgentValet
|
|
70
|
+
|
|
71
|
+
av = AgentValet.from_env()
|
|
72
|
+
|
|
73
|
+
result = av.call(
|
|
74
|
+
platform="slack",
|
|
75
|
+
endpoint="/api/chat.postMessage",
|
|
76
|
+
method="POST",
|
|
77
|
+
scope="chat:write",
|
|
78
|
+
data={"channel": "#general", "text": "Deploy finished."},
|
|
79
|
+
)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`from_env()` reads `AGENTVALET_AGENT_ID` / `AGENTVALET_OWNER_ID` (or the bare
|
|
83
|
+
`AGENT_ID` / `OWNER_ID`) and finds the key via `AGENT_PRIVATE_KEY_B64`,
|
|
84
|
+
`AGENT_PRIVATE_KEY_PATH`, `AGENT_PRIVATE_KEY`, or `~/.agentvalet/agent.key`. To
|
|
85
|
+
wire it explicitly:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
av = AgentValet(
|
|
89
|
+
agent_id=os.environ["AGENT_ID"],
|
|
90
|
+
owner_id=os.environ["OWNER_ID"],
|
|
91
|
+
private_key=os.environ["AGENT_PRIVATE_KEY"],
|
|
92
|
+
proxy_url="https://api.agentvalet.ai", # default
|
|
93
|
+
)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Use it as a context manager to close the HTTP client cleanly:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
with AgentValet.from_env() as av:
|
|
100
|
+
av.call(platform="github", endpoint="/user", scope="read")
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Async
|
|
104
|
+
|
|
105
|
+
`AsyncAgentValet` is a method-for-method mirror. All decision logic is shared,
|
|
106
|
+
and the test suite runs the same behaviour table against both.
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from agentvalet import AsyncAgentValet
|
|
110
|
+
|
|
111
|
+
async with AsyncAgentValet.from_env() as av:
|
|
112
|
+
result = await av.call(
|
|
113
|
+
platform="slack", endpoint="/api/chat.postMessage",
|
|
114
|
+
method="POST", scope="chat:write", data={...},
|
|
115
|
+
)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Approvals are just a slower call
|
|
119
|
+
|
|
120
|
+
When the owner has marked a scope as approval-gated, the proxy holds the action
|
|
121
|
+
and `call()` waits. If the owner approves, the proxy re-runs the call and you
|
|
122
|
+
get the result — from your code's point of view it simply took longer.
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
av = AgentValet.from_env(
|
|
126
|
+
on_approval_pending=lambda i: print(f"waiting… {i['elapsed_s']:.0f}s")
|
|
127
|
+
)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
If nobody responds inside the budget (50s by default), you get an
|
|
131
|
+
`ApprovalTimeoutError` — **not a failure**. The action stays queued. Keep the
|
|
132
|
+
`approval_id` and resume whenever you like, in this process or another:
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
from agentvalet import ApprovalTimeoutError
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
av.call(platform="stripe", endpoint="/v1/refunds", method="POST", scope="charge")
|
|
139
|
+
except ApprovalTimeoutError as err:
|
|
140
|
+
queue.put(err.approval_id) # hand off to a worker
|
|
141
|
+
|
|
142
|
+
# …later, elsewhere:
|
|
143
|
+
result = av.wait_for_approval(approval_id)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Pass `approval_timeout_s=0` if you never want to block.
|
|
147
|
+
|
|
148
|
+
## Errors you can branch on
|
|
149
|
+
|
|
150
|
+
Every exception is typed, so you never string-match an error envelope:
|
|
151
|
+
|
|
152
|
+
| Error | Means | What to do |
|
|
153
|
+
|---|---|---|
|
|
154
|
+
| `ConfigError` | Bad/missing identity or key | Fix config; raised before any network call |
|
|
155
|
+
| `NetworkError` | Transport failed | `.hint` diagnoses DNS / TLS / firewall / timeout |
|
|
156
|
+
| `AccessDeniedError` | No grant, or policy blocked it | `request_access()` — see below |
|
|
157
|
+
| `ApprovalDeniedError` | Owner said no | Terminal. Don't retry |
|
|
158
|
+
| `ApprovalExpiredError` | Aged out server-side | Re-issue the call |
|
|
159
|
+
| `ApprovalTimeoutError` | You stopped waiting | Resume via `wait_for_approval()` |
|
|
160
|
+
| `UpstreamError` | The SaaS returned non-2xx | `.status` / `.data` hold the upstream reply |
|
|
161
|
+
| `ProxyError` | Anything else from the broker | `.status` / `.body` |
|
|
162
|
+
|
|
163
|
+
## Asking for access you don't have
|
|
164
|
+
|
|
165
|
+
Deny-by-default means a scope you were never granted raises
|
|
166
|
+
`AccessDeniedError`. Your agent can ask for it:
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
result = av.request_access(
|
|
170
|
+
platform="slack",
|
|
171
|
+
scope="chat:write",
|
|
172
|
+
reason="Post deploy notifications to #general",
|
|
173
|
+
)
|
|
174
|
+
if result["status"] == "approved":
|
|
175
|
+
... # retry the original call
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Checking before you act
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
av.list_platforms() # what this agent is actually granted
|
|
182
|
+
av.pending_actions() # queued behind an approval
|
|
183
|
+
av.evaluate("stripe", "charge") # dry-run the decision, no side effect
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
`evaluate()` is worth calling before anything destructive.
|
|
187
|
+
|
|
188
|
+
## Self-hosting
|
|
189
|
+
|
|
190
|
+
Point `proxy_url` at your own deployment. Everything else is identical.
|
|
191
|
+
|
|
192
|
+
## License
|
|
193
|
+
|
|
194
|
+
MIT
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# agentvalet
|
|
2
|
+
|
|
3
|
+
Call approved SaaS platforms from any Python agent. Your agent never holds the
|
|
4
|
+
downstream credential — AgentValet signs a short-lived identity assertion,
|
|
5
|
+
checks the call against the owner's grants and policy, injects the credential at
|
|
6
|
+
call time, and writes an audit record.
|
|
7
|
+
|
|
8
|
+
This is the Python port of
|
|
9
|
+
[`@agentvalet/client`](https://www.npmjs.com/package/@agentvalet/client). Same
|
|
10
|
+
endpoints, same approval semantics, same timings.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install agentvalet
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Python 3.9+. Two dependencies: `httpx` and `pyjwt[crypto]`.
|
|
19
|
+
|
|
20
|
+
## Get an agent identity
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
agentvalet register --code <invite-or-enrollment-code>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The RSA keypair is generated **on your machine**. Only the public half is sent;
|
|
27
|
+
the private key is written to `~/.agentvalet/agent.key` (mode 0600) and never
|
|
28
|
+
crosses the wire. The code is either an invite's bind secret or the enrollment
|
|
29
|
+
code from the "Try it free" flow.
|
|
30
|
+
|
|
31
|
+
This writes the same files as `npx @agentvalet/register`, so the Node and Python
|
|
32
|
+
tooling are interchangeable — you do not need Node installed.
|
|
33
|
+
|
|
34
|
+
## Use it
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from agentvalet import AgentValet
|
|
38
|
+
|
|
39
|
+
av = AgentValet.from_env()
|
|
40
|
+
|
|
41
|
+
result = av.call(
|
|
42
|
+
platform="slack",
|
|
43
|
+
endpoint="/api/chat.postMessage",
|
|
44
|
+
method="POST",
|
|
45
|
+
scope="chat:write",
|
|
46
|
+
data={"channel": "#general", "text": "Deploy finished."},
|
|
47
|
+
)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`from_env()` reads `AGENTVALET_AGENT_ID` / `AGENTVALET_OWNER_ID` (or the bare
|
|
51
|
+
`AGENT_ID` / `OWNER_ID`) and finds the key via `AGENT_PRIVATE_KEY_B64`,
|
|
52
|
+
`AGENT_PRIVATE_KEY_PATH`, `AGENT_PRIVATE_KEY`, or `~/.agentvalet/agent.key`. To
|
|
53
|
+
wire it explicitly:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
av = AgentValet(
|
|
57
|
+
agent_id=os.environ["AGENT_ID"],
|
|
58
|
+
owner_id=os.environ["OWNER_ID"],
|
|
59
|
+
private_key=os.environ["AGENT_PRIVATE_KEY"],
|
|
60
|
+
proxy_url="https://api.agentvalet.ai", # default
|
|
61
|
+
)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Use it as a context manager to close the HTTP client cleanly:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
with AgentValet.from_env() as av:
|
|
68
|
+
av.call(platform="github", endpoint="/user", scope="read")
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Async
|
|
72
|
+
|
|
73
|
+
`AsyncAgentValet` is a method-for-method mirror. All decision logic is shared,
|
|
74
|
+
and the test suite runs the same behaviour table against both.
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from agentvalet import AsyncAgentValet
|
|
78
|
+
|
|
79
|
+
async with AsyncAgentValet.from_env() as av:
|
|
80
|
+
result = await av.call(
|
|
81
|
+
platform="slack", endpoint="/api/chat.postMessage",
|
|
82
|
+
method="POST", scope="chat:write", data={...},
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Approvals are just a slower call
|
|
87
|
+
|
|
88
|
+
When the owner has marked a scope as approval-gated, the proxy holds the action
|
|
89
|
+
and `call()` waits. If the owner approves, the proxy re-runs the call and you
|
|
90
|
+
get the result — from your code's point of view it simply took longer.
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
av = AgentValet.from_env(
|
|
94
|
+
on_approval_pending=lambda i: print(f"waiting… {i['elapsed_s']:.0f}s")
|
|
95
|
+
)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
If nobody responds inside the budget (50s by default), you get an
|
|
99
|
+
`ApprovalTimeoutError` — **not a failure**. The action stays queued. Keep the
|
|
100
|
+
`approval_id` and resume whenever you like, in this process or another:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from agentvalet import ApprovalTimeoutError
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
av.call(platform="stripe", endpoint="/v1/refunds", method="POST", scope="charge")
|
|
107
|
+
except ApprovalTimeoutError as err:
|
|
108
|
+
queue.put(err.approval_id) # hand off to a worker
|
|
109
|
+
|
|
110
|
+
# …later, elsewhere:
|
|
111
|
+
result = av.wait_for_approval(approval_id)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Pass `approval_timeout_s=0` if you never want to block.
|
|
115
|
+
|
|
116
|
+
## Errors you can branch on
|
|
117
|
+
|
|
118
|
+
Every exception is typed, so you never string-match an error envelope:
|
|
119
|
+
|
|
120
|
+
| Error | Means | What to do |
|
|
121
|
+
|---|---|---|
|
|
122
|
+
| `ConfigError` | Bad/missing identity or key | Fix config; raised before any network call |
|
|
123
|
+
| `NetworkError` | Transport failed | `.hint` diagnoses DNS / TLS / firewall / timeout |
|
|
124
|
+
| `AccessDeniedError` | No grant, or policy blocked it | `request_access()` — see below |
|
|
125
|
+
| `ApprovalDeniedError` | Owner said no | Terminal. Don't retry |
|
|
126
|
+
| `ApprovalExpiredError` | Aged out server-side | Re-issue the call |
|
|
127
|
+
| `ApprovalTimeoutError` | You stopped waiting | Resume via `wait_for_approval()` |
|
|
128
|
+
| `UpstreamError` | The SaaS returned non-2xx | `.status` / `.data` hold the upstream reply |
|
|
129
|
+
| `ProxyError` | Anything else from the broker | `.status` / `.body` |
|
|
130
|
+
|
|
131
|
+
## Asking for access you don't have
|
|
132
|
+
|
|
133
|
+
Deny-by-default means a scope you were never granted raises
|
|
134
|
+
`AccessDeniedError`. Your agent can ask for it:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
result = av.request_access(
|
|
138
|
+
platform="slack",
|
|
139
|
+
scope="chat:write",
|
|
140
|
+
reason="Post deploy notifications to #general",
|
|
141
|
+
)
|
|
142
|
+
if result["status"] == "approved":
|
|
143
|
+
... # retry the original call
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Checking before you act
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
av.list_platforms() # what this agent is actually granted
|
|
150
|
+
av.pending_actions() # queued behind an approval
|
|
151
|
+
av.evaluate("stripe", "charge") # dry-run the decision, no side effect
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
`evaluate()` is worth calling before anything destructive.
|
|
155
|
+
|
|
156
|
+
## Self-hosting
|
|
157
|
+
|
|
158
|
+
Point `proxy_url` at your own deployment. Everything else is identical.
|
|
159
|
+
|
|
160
|
+
## License
|
|
161
|
+
|
|
162
|
+
MIT
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "agentvalet"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Call approved SaaS platforms from any Python agent. Your agent never holds the downstream credential."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "AgentValet" }]
|
|
13
|
+
keywords = ["ai", "agents", "agentvalet", "credentials", "authorization", "sdk", "mcp"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.9",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Topic :: Security",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"httpx>=0.24",
|
|
29
|
+
# [crypto] pulls `cryptography`, which also gives us RSA keygen for
|
|
30
|
+
# `agentvalet register` at no extra dependency cost.
|
|
31
|
+
"pyjwt[crypto]>=2.6",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
dev = [
|
|
36
|
+
"pytest>=7.4",
|
|
37
|
+
"pytest-asyncio>=0.21",
|
|
38
|
+
"respx>=0.20",
|
|
39
|
+
"mypy>=1.8",
|
|
40
|
+
"ruff>=0.4",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[project.scripts]
|
|
44
|
+
agentvalet = "agentvalet.register:main"
|
|
45
|
+
|
|
46
|
+
[project.urls]
|
|
47
|
+
Homepage = "https://agentvalet.ai"
|
|
48
|
+
Documentation = "https://docs.agentvalet.ai"
|
|
49
|
+
Source = "https://github.com/MCSEdwin/agentvalet"
|
|
50
|
+
|
|
51
|
+
[tool.hatch.build.targets.wheel]
|
|
52
|
+
packages = ["src/agentvalet"]
|
|
53
|
+
|
|
54
|
+
[tool.pytest.ini_options]
|
|
55
|
+
testpaths = ["tests"]
|
|
56
|
+
asyncio_mode = "auto"
|
|
57
|
+
|
|
58
|
+
[tool.ruff]
|
|
59
|
+
line-length = 100
|
|
60
|
+
target-version = "py39"
|
|
61
|
+
|
|
62
|
+
# Pin the rule set explicitly. Relying on ruff's defaults means the lint
|
|
63
|
+
# surface silently changes on a ruff upgrade.
|
|
64
|
+
[tool.ruff.lint]
|
|
65
|
+
select = ["E", "F", "I", "UP", "B", "RUF"]
|
|
66
|
+
|
|
67
|
+
[tool.ruff.lint.per-file-ignores]
|
|
68
|
+
# Tests import their own conftest by name and shadow builtins freely.
|
|
69
|
+
"tests/*" = ["E402", "E501"] # long inline fixtures read better unwrapped
|
|
70
|
+
|
|
71
|
+
[tool.mypy]
|
|
72
|
+
# No python_version pin: recent mypy refuses 3.9 as a target, and the real
|
|
73
|
+
# floor is enforced by requires-python. Strict still applies.
|
|
74
|
+
strict = true
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""agentvalet — call approved SaaS platforms from any Python agent.
|
|
2
|
+
|
|
3
|
+
Your agent never holds the downstream credential. This client signs a
|
|
4
|
+
short-lived RS256 identity assertion; the AgentValet broker checks it against
|
|
5
|
+
the owner's grants and policy, injects the credential at call time, and writes
|
|
6
|
+
an audit record.
|
|
7
|
+
|
|
8
|
+
from agentvalet import AgentValet
|
|
9
|
+
|
|
10
|
+
av = AgentValet.from_env()
|
|
11
|
+
av.call(
|
|
12
|
+
platform="slack",
|
|
13
|
+
endpoint="/api/chat.postMessage",
|
|
14
|
+
method="POST",
|
|
15
|
+
scope="chat:write",
|
|
16
|
+
data={"channel": "#general", "text": "Deploy finished."},
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
Use :class:`agentvalet.aio.AsyncAgentValet` for the async equivalent.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from ._constants import DEFAULT_PROXY_URL
|
|
25
|
+
from .aio import AsyncAgentValet
|
|
26
|
+
from .client import AgentValet
|
|
27
|
+
from .errors import (
|
|
28
|
+
AccessDeniedError,
|
|
29
|
+
AgentValetError,
|
|
30
|
+
ApprovalDeniedError,
|
|
31
|
+
ApprovalExpiredError,
|
|
32
|
+
ApprovalTimeoutError,
|
|
33
|
+
ConfigError,
|
|
34
|
+
NetworkError,
|
|
35
|
+
ProxyError,
|
|
36
|
+
UpstreamError,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
__version__ = "0.1.0"
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"DEFAULT_PROXY_URL",
|
|
43
|
+
"AccessDeniedError",
|
|
44
|
+
"AgentValet",
|
|
45
|
+
"AgentValetError",
|
|
46
|
+
"ApprovalDeniedError",
|
|
47
|
+
"ApprovalExpiredError",
|
|
48
|
+
"ApprovalTimeoutError",
|
|
49
|
+
"AsyncAgentValet",
|
|
50
|
+
"ConfigError",
|
|
51
|
+
"NetworkError",
|
|
52
|
+
"ProxyError",
|
|
53
|
+
"UpstreamError",
|
|
54
|
+
"__version__",
|
|
55
|
+
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Behavioural contract with the AgentValet proxy.
|
|
2
|
+
|
|
3
|
+
These are NOT preferences. Each value is matched by
|
|
4
|
+
``packages/client/src/client.ts`` (TypeScript) and ``apps/mcp-server``. If one
|
|
5
|
+
of them changes here and not there, an operator debugging the Python SDK is no
|
|
6
|
+
longer debugging the same system as the Node one.
|
|
7
|
+
|
|
8
|
+
``tests/test_constants.py`` asserts every value, so changing one is a
|
|
9
|
+
deliberate, reviewed act rather than a drive-by edit.
|
|
10
|
+
|
|
11
|
+
Note the unit: the TypeScript client uses milliseconds; Python uses **seconds**,
|
|
12
|
+
matching the httpx/requests convention. The underlying values are the same.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
DEFAULT_PROXY_URL = "https://api.agentvalet.ai"
|
|
16
|
+
|
|
17
|
+
#: Approval long-poll budget. Sits under Claude Desktop's hardcoded 60s tool
|
|
18
|
+
#: timeout so the MCP path and the SDK path give up at the same moment.
|
|
19
|
+
APPROVAL_TIMEOUT_S = 50.0
|
|
20
|
+
|
|
21
|
+
#: How often we ask the proxy whether the owner has decided.
|
|
22
|
+
APPROVAL_POLL_S = 2.0
|
|
23
|
+
|
|
24
|
+
#: Per-request network timeout.
|
|
25
|
+
REQUEST_TIMEOUT_S = 15.0
|
|
26
|
+
|
|
27
|
+
#: Agent assertions are deliberately short-lived; the proxy rejects long-dated
|
|
28
|
+
#: ones. Seconds.
|
|
29
|
+
JWT_LIFETIME_S = 60
|
|
30
|
+
|
|
31
|
+
JWT_ALGORITHM = "RS256"
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Pure protocol logic — no I/O, no awaits.
|
|
2
|
+
|
|
3
|
+
Everything that decides *what* to send and *what a response means* lives here,
|
|
4
|
+
so the sync and async clients can share it verbatim. Only transport differs
|
|
5
|
+
between them. If you find yourself adding a branch to one client and not the
|
|
6
|
+
other, it belongs in this module instead.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .errors import (
|
|
16
|
+
AccessDeniedError,
|
|
17
|
+
ApprovalDeniedError,
|
|
18
|
+
ApprovalExpiredError,
|
|
19
|
+
ProxyError,
|
|
20
|
+
UpstreamError,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_action_body(
|
|
25
|
+
platform: str,
|
|
26
|
+
endpoint: str,
|
|
27
|
+
scope: str,
|
|
28
|
+
method: str = "GET",
|
|
29
|
+
data: Any = None,
|
|
30
|
+
connection_id: str | None = None,
|
|
31
|
+
reason: str | None = None,
|
|
32
|
+
) -> dict[str, Any]:
|
|
33
|
+
"""Wire body for ``POST /v1/actions``.
|
|
34
|
+
|
|
35
|
+
Optional fields are omitted entirely rather than sent as null — the proxy
|
|
36
|
+
distinguishes absent from null for ``data``.
|
|
37
|
+
"""
|
|
38
|
+
body: dict[str, Any] = {
|
|
39
|
+
"platform": platform,
|
|
40
|
+
"endpoint": endpoint,
|
|
41
|
+
"method": method,
|
|
42
|
+
"scope": scope,
|
|
43
|
+
}
|
|
44
|
+
if data is not None:
|
|
45
|
+
body["data"] = data
|
|
46
|
+
if connection_id:
|
|
47
|
+
body["connection_id"] = connection_id
|
|
48
|
+
if reason:
|
|
49
|
+
body["reason"] = reason
|
|
50
|
+
return body
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def authzen_body(agent_id: str, platform: str, scope: str) -> dict[str, Any]:
|
|
54
|
+
return {
|
|
55
|
+
"subject": {"type": "agent", "id": agent_id},
|
|
56
|
+
"action": {"name": "tool_call"},
|
|
57
|
+
"resource": {"type": "platform_scope", "id": f"{platform}:{scope}"},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def try_parse_json(text: str) -> Any:
|
|
62
|
+
"""Parse only if it looks structured; otherwise hand back the raw text.
|
|
63
|
+
|
|
64
|
+
Mirrors the Node client so a non-JSON upstream body (a plain-text error,
|
|
65
|
+
an empty 204) surfaces identically in both SDKs.
|
|
66
|
+
"""
|
|
67
|
+
trimmed = text.strip()
|
|
68
|
+
if not (trimmed.startswith("{") or trimmed.startswith("[")):
|
|
69
|
+
return text
|
|
70
|
+
try:
|
|
71
|
+
return json.loads(trimmed)
|
|
72
|
+
except ValueError:
|
|
73
|
+
return text
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def approval_id_from_202(text: str) -> str | None:
|
|
77
|
+
parsed = try_parse_json(text)
|
|
78
|
+
if isinstance(parsed, dict):
|
|
79
|
+
value = parsed.get("approval_id")
|
|
80
|
+
if isinstance(value, str):
|
|
81
|
+
return value
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def interpret_action_response(
|
|
86
|
+
status: int, text: str, platform: str, scope: str
|
|
87
|
+
) -> Any:
|
|
88
|
+
"""Turn a ``/v1/actions`` reply into a value or the right exception.
|
|
89
|
+
|
|
90
|
+
Callers must handle the 202 branch *before* this — an approval is a
|
|
91
|
+
continuation, not a result.
|
|
92
|
+
"""
|
|
93
|
+
if status == 403:
|
|
94
|
+
# The proxy's authorize pipeline denies with 403 for both "no grant"
|
|
95
|
+
# and "policy blocked". Its own type so callers can branch into
|
|
96
|
+
# request_access() instead of dead-ending on a generic failure.
|
|
97
|
+
raise AccessDeniedError(status, text, platform, scope)
|
|
98
|
+
if not 200 <= status < 300:
|
|
99
|
+
raise ProxyError(status, text)
|
|
100
|
+
return try_parse_json(text)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def interpret_approval_status(payload: Mapping[str, Any], approval_id: str) -> tuple[bool, Any]:
|
|
104
|
+
"""Interpret one poll of ``GET /v1/approvals/:id``.
|
|
105
|
+
|
|
106
|
+
Returns ``(done, value)``. ``done=False`` means keep polling. Terminal
|
|
107
|
+
failures raise.
|
|
108
|
+
"""
|
|
109
|
+
status = payload.get("status")
|
|
110
|
+
|
|
111
|
+
if status == "denied":
|
|
112
|
+
raise ApprovalDeniedError(approval_id, _as_str(payload.get("execution_error")))
|
|
113
|
+
if status == "expired":
|
|
114
|
+
raise ApprovalExpiredError(approval_id)
|
|
115
|
+
|
|
116
|
+
execution_error = payload.get("execution_error")
|
|
117
|
+
if execution_error:
|
|
118
|
+
# Approved, but re-execution failed (upstream down, agent revoked
|
|
119
|
+
# between approval and execution).
|
|
120
|
+
raise ProxyError(502, f"Approved but re-execution failed: {execution_error}")
|
|
121
|
+
|
|
122
|
+
if status == "approved" and payload.get("executed_at") and payload.get("result"):
|
|
123
|
+
result = payload["result"]
|
|
124
|
+
upstream_status = int(result.get("status", 0))
|
|
125
|
+
if not 200 <= upstream_status < 300:
|
|
126
|
+
raise UpstreamError(upstream_status, result.get("data"))
|
|
127
|
+
return True, result.get("data")
|
|
128
|
+
|
|
129
|
+
return False, None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def diagnose_network_error(err: BaseException, proxy_url: str) -> str:
|
|
133
|
+
"""Turn a transport failure into something a developer can act on.
|
|
134
|
+
|
|
135
|
+
httpx's bare "Connection error" says nothing about whether it's DNS, a
|
|
136
|
+
corporate MITM proxy, or the broker being down.
|
|
137
|
+
"""
|
|
138
|
+
raw = str(err)
|
|
139
|
+
lower = raw.lower()
|
|
140
|
+
|
|
141
|
+
if "getaddrinfo" in lower or "name or service not known" in lower or "nodename" in lower:
|
|
142
|
+
return (
|
|
143
|
+
f"Cannot resolve {proxy_url}. Check DNS / corporate proxy / VPN, or confirm "
|
|
144
|
+
f"proxy_url is correct. Raw: {raw}"
|
|
145
|
+
)
|
|
146
|
+
if "refused" in lower or "reset" in lower:
|
|
147
|
+
return (
|
|
148
|
+
f"Connection to {proxy_url} was refused or reset. The broker may be down — check "
|
|
149
|
+
f"https://status.agentvalet.ai — or a firewall is blocking it. Raw: {raw}"
|
|
150
|
+
)
|
|
151
|
+
if "timeout" in lower or "timed out" in lower:
|
|
152
|
+
return (
|
|
153
|
+
f"Request to {proxy_url} timed out. Likely VPN routing, corporate proxy buffering, "
|
|
154
|
+
f"or a slow network. Raw: {raw}"
|
|
155
|
+
)
|
|
156
|
+
if "certificate" in lower or "ssl" in lower or "tls" in lower:
|
|
157
|
+
return (
|
|
158
|
+
f"TLS / certificate problem talking to {proxy_url}. A corporate MITM proxy may be "
|
|
159
|
+
f"intercepting traffic. Raw: {raw}"
|
|
160
|
+
)
|
|
161
|
+
return (
|
|
162
|
+
f"Network error reaching {proxy_url}: {raw}. Check VPN, corporate proxy, and firewall "
|
|
163
|
+
f"rules for api.agentvalet.ai."
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _as_str(value: Any) -> str | None:
|
|
168
|
+
return value if isinstance(value, str) else None
|