agentvalet 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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
agentvalet/_signing.py ADDED
@@ -0,0 +1,33 @@
1
+ """RS256 agent assertion minting.
2
+
3
+ Split out so both clients share it and so the failure mode is one place: a
4
+ malformed PEM must surface as :class:`ConfigError`, never as a raw PyJWT or
5
+ cryptography traceback that sends the reader looking in the wrong library.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from typing import Any
12
+
13
+ import jwt
14
+
15
+ from ._constants import JWT_ALGORITHM, JWT_LIFETIME_S
16
+ from .errors import ConfigError
17
+
18
+
19
+ def sign_jwt(agent_id: str, owner_id: str, private_key_pem: str) -> str:
20
+ now = int(time.time())
21
+ claims: dict[str, Any] = {
22
+ "agent_id": agent_id,
23
+ "owner_id": owner_id,
24
+ "iat": now,
25
+ "exp": now + JWT_LIFETIME_S,
26
+ }
27
+ try:
28
+ return jwt.encode(claims, private_key_pem, algorithm=JWT_ALGORITHM)
29
+ except Exception as err:
30
+ raise ConfigError(
31
+ f"Private key is not a valid RS256 PEM: {err}. Expected a PKCS#8 private key — "
32
+ "run `agentvalet register` to generate one."
33
+ ) from err
agentvalet/aio.py ADDED
@@ -0,0 +1,267 @@
1
+ """Asynchronous AgentValet client.
2
+
3
+ A method-for-method mirror of :class:`agentvalet.client.AgentValet`. All the
4
+ decision logic lives in :mod:`agentvalet._protocol`, which both clients share,
5
+ so the only thing that differs here is the transport and the awaits. The test
6
+ suite runs the same behaviour table against both classes to keep it that way.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import time
13
+ from collections.abc import Awaitable
14
+ from typing import Any, Callable, Union
15
+
16
+ import httpx
17
+
18
+ from ._constants import (
19
+ APPROVAL_POLL_S,
20
+ APPROVAL_TIMEOUT_S,
21
+ DEFAULT_PROXY_URL,
22
+ REQUEST_TIMEOUT_S,
23
+ )
24
+ from ._protocol import (
25
+ approval_id_from_202,
26
+ authzen_body,
27
+ build_action_body,
28
+ diagnose_network_error,
29
+ interpret_action_response,
30
+ interpret_approval_status,
31
+ try_parse_json,
32
+ )
33
+ from ._signing import sign_jwt
34
+ from .env import agent_id_from_env, owner_id_from_env, private_key_from_env, proxy_url_from_env
35
+ from .errors import ApprovalTimeoutError, ConfigError, NetworkError, ProxyError
36
+
37
+ __all__ = ["AsyncAgentValet"]
38
+
39
+ AsyncProgressCallback = Callable[[dict[str, Any]], Union[Awaitable[None], None]]
40
+
41
+
42
+ class AsyncAgentValet:
43
+ """Async twin of :class:`agentvalet.client.AgentValet`."""
44
+
45
+ def __init__(
46
+ self,
47
+ agent_id: str,
48
+ owner_id: str,
49
+ private_key: str,
50
+ proxy_url: str = DEFAULT_PROXY_URL,
51
+ timeout_s: float = REQUEST_TIMEOUT_S,
52
+ approval_timeout_s: float = APPROVAL_TIMEOUT_S,
53
+ approval_poll_s: float = APPROVAL_POLL_S,
54
+ on_approval_pending: AsyncProgressCallback | None = None,
55
+ transport: httpx.AsyncClient | None = None,
56
+ ) -> None:
57
+ if not agent_id:
58
+ raise ConfigError("agent_id is required")
59
+ if not owner_id:
60
+ raise ConfigError("owner_id is required")
61
+ if not private_key:
62
+ raise ConfigError("private_key is required")
63
+
64
+ self.agent_id = agent_id
65
+ self.owner_id = owner_id
66
+ self.proxy_url = proxy_url.rstrip("/")
67
+ self._private_key = private_key
68
+ self._timeout_s = timeout_s
69
+ self._approval_timeout_s = approval_timeout_s
70
+ self._approval_poll_s = approval_poll_s
71
+ self._on_approval_pending = on_approval_pending
72
+ self._owns_client = transport is None
73
+ self._http = transport or httpx.AsyncClient(timeout=timeout_s)
74
+
75
+ @classmethod
76
+ def from_env(cls, **overrides: Any) -> AsyncAgentValet:
77
+ agent_id = overrides.pop("agent_id", None) or agent_id_from_env()
78
+ owner_id = overrides.pop("owner_id", None) or owner_id_from_env()
79
+ if not agent_id or not owner_id:
80
+ raise ConfigError(
81
+ "AgentValet identity not found in the environment. Set AGENTVALET_AGENT_ID and "
82
+ "AGENTVALET_OWNER_ID (or AGENT_ID / OWNER_ID), or run `agentvalet register`."
83
+ )
84
+ return cls(
85
+ agent_id=agent_id,
86
+ owner_id=owner_id,
87
+ private_key=overrides.pop("private_key", None) or private_key_from_env(),
88
+ proxy_url=overrides.pop("proxy_url", None) or proxy_url_from_env(),
89
+ **overrides,
90
+ )
91
+
92
+ async def __aenter__(self) -> AsyncAgentValet:
93
+ return self
94
+
95
+ async def __aexit__(self, *exc: Any) -> None:
96
+ await self.aclose()
97
+
98
+ async def aclose(self) -> None:
99
+ if self._owns_client:
100
+ await self._http.aclose()
101
+
102
+ # -- core --------------------------------------------------------------
103
+
104
+ async def call(
105
+ self,
106
+ platform: str,
107
+ endpoint: str,
108
+ scope: str,
109
+ method: str = "GET",
110
+ data: Any = None,
111
+ connection_id: str | None = None,
112
+ reason: str | None = None,
113
+ approval_timeout_s: float | None = None,
114
+ ) -> Any:
115
+ """See :meth:`agentvalet.client.AgentValet.call`."""
116
+ body = build_action_body(
117
+ platform=platform,
118
+ endpoint=endpoint,
119
+ scope=scope,
120
+ method=method,
121
+ data=data,
122
+ connection_id=connection_id,
123
+ reason=reason,
124
+ )
125
+ response = await self._authed_request("POST", "/v1/actions", json=body)
126
+ text = response.text
127
+
128
+ if response.status_code == 202:
129
+ approval_id = approval_id_from_202(text)
130
+ if approval_id:
131
+ return await self.wait_for_approval(
132
+ approval_id,
133
+ platform=platform,
134
+ scope=scope,
135
+ endpoint=endpoint,
136
+ timeout_s=approval_timeout_s,
137
+ )
138
+
139
+ return interpret_action_response(response.status_code, text, platform, scope)
140
+
141
+ async def wait_for_approval(
142
+ self,
143
+ approval_id: str,
144
+ platform: str = "",
145
+ scope: str = "",
146
+ endpoint: str = "",
147
+ timeout_s: float | None = None,
148
+ ) -> Any:
149
+ """See :meth:`agentvalet.client.AgentValet.wait_for_approval`."""
150
+ budget = self._approval_timeout_s if timeout_s is None else timeout_s
151
+ started = time.monotonic()
152
+
153
+ while time.monotonic() - started < budget:
154
+ await asyncio.sleep(self._approval_poll_s)
155
+ elapsed = time.monotonic() - started
156
+
157
+ try:
158
+ response = await self._authed_request("GET", f"/v1/approvals/{approval_id}")
159
+ except NetworkError:
160
+ continue
161
+
162
+ if response.status_code >= 400:
163
+ raise ProxyError(response.status_code, response.text)
164
+
165
+ done, value = interpret_approval_status(response.json(), approval_id)
166
+ if done:
167
+ return value
168
+
169
+ if self._on_approval_pending:
170
+ maybe = self._on_approval_pending(
171
+ {
172
+ "approval_id": approval_id,
173
+ "elapsed_s": elapsed,
174
+ "platform": platform,
175
+ "scope": scope,
176
+ "endpoint": endpoint,
177
+ }
178
+ )
179
+ # Accept either a plain callback or a coroutine function.
180
+ if asyncio.iscoroutine(maybe):
181
+ await maybe
182
+
183
+ raise ApprovalTimeoutError(approval_id, budget)
184
+
185
+ # -- discovery / governance -------------------------------------------
186
+
187
+ async def list_platforms(self) -> Any:
188
+ return await self._get_json("/v1/agent/permissions")
189
+
190
+ async def pending_actions(self) -> Any:
191
+ return await self._get_json("/v1/agents/me/pending-actions")
192
+
193
+ async def evaluate(self, platform: str, scope: str) -> Any:
194
+ response = await self._authed_request(
195
+ "POST", "/v1/authzen/access", json=authzen_body(self.agent_id, platform, scope)
196
+ )
197
+ if response.status_code >= 400:
198
+ raise ProxyError(response.status_code, response.text)
199
+ return try_parse_json(response.text)
200
+
201
+ async def request_access(
202
+ self,
203
+ platform: str,
204
+ scope: str | None = None,
205
+ reason: str | None = None,
206
+ timeout_s: float | None = None,
207
+ ) -> dict[str, str]:
208
+ submit = await self._authed_request(
209
+ "POST",
210
+ "/v1/access-request",
211
+ json={"platform": platform, "scope": scope, "reason": reason},
212
+ )
213
+ if submit.status_code >= 400:
214
+ raise ProxyError(submit.status_code, submit.text)
215
+
216
+ token = submit.json().get("request_token")
217
+ if not token:
218
+ raise ProxyError(submit.status_code, "Access request did not return a request_token.")
219
+
220
+ budget = self._approval_timeout_s if timeout_s is None else timeout_s
221
+ deadline = time.monotonic() + budget
222
+ while time.monotonic() < deadline:
223
+ await asyncio.sleep(self._approval_poll_s)
224
+ try:
225
+ status_res = await self._authed_request("GET", f"/v1/access-request/status/{token}")
226
+ except NetworkError:
227
+ continue
228
+ if status_res.status_code >= 400:
229
+ continue
230
+ status = status_res.json().get("status")
231
+ if status in ("approved", "denied"):
232
+ return {"status": status, "request_token": token}
233
+
234
+ return {"status": "pending", "request_token": token}
235
+
236
+ # -- transport ---------------------------------------------------------
237
+
238
+ def sign_jwt(self) -> str:
239
+ return sign_jwt(self.agent_id, self.owner_id, self._private_key)
240
+
241
+ async def _authed_request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
242
+ url = f"{self.proxy_url}{path}"
243
+
244
+ async def once() -> httpx.Response:
245
+ token = self.sign_jwt()
246
+ try:
247
+ return await self._http.request(
248
+ method,
249
+ url,
250
+ headers={
251
+ "Authorization": f"Bearer {token}",
252
+ "Content-Type": "application/json",
253
+ },
254
+ timeout=self._timeout_s,
255
+ **kwargs,
256
+ )
257
+ except httpx.HTTPError as err:
258
+ raise NetworkError(diagnose_network_error(err, self.proxy_url), err) from err
259
+
260
+ response = await once()
261
+ return await once() if response.status_code == 401 else response
262
+
263
+ async def _get_json(self, path: str) -> Any:
264
+ response = await self._authed_request("GET", path)
265
+ if response.status_code >= 400:
266
+ raise ProxyError(response.status_code, response.text)
267
+ return try_parse_json(response.text)