prosader 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.
prosader/__init__.py ADDED
@@ -0,0 +1,378 @@
1
+ """Prosader Python SDK — guard AI agent tool calls with a Prosader gateway.
2
+
3
+ Every guarded call is evaluated against your Prosader policy and produces a
4
+ signed, tamper-evident audit receipt, whatever the outcome.
5
+
6
+ Quick start::
7
+
8
+ import prosader
9
+
10
+ client = prosader.Client(
11
+ gateway_url="https://gateway.example.com:8443",
12
+ api_key="psk-live-...", # or PROSADER_API_KEY
13
+ agent_id="billing-agent", # or PROSADER_AGENT_ID
14
+ )
15
+
16
+ # Explicit check before executing a tool yourself:
17
+ decision = client.check("bash", parameters={"command": "ls /tmp"})
18
+ if decision.allowed:
19
+ ... # run the tool
20
+
21
+ # Or wrap a function so the check happens automatically:
22
+ @client.wrap("send_email")
23
+ def send_email(to, subject, body): ...
24
+
25
+ send_email(to="a@b.com", subject="hi", body="...") # raises prosader.Denied if blocked
26
+
27
+ The SDK is intentionally dependency-free (Python standard library only).
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import functools
33
+ import inspect
34
+ import json
35
+ import os
36
+ import ssl
37
+ import urllib.error
38
+ import urllib.request
39
+ import uuid
40
+ from dataclasses import dataclass, field
41
+ from datetime import datetime, timezone
42
+ from typing import Any, Callable, Dict, Optional
43
+
44
+ __all__ = [
45
+ "Client",
46
+ "Decision",
47
+ "ProsaderError",
48
+ "Denied",
49
+ "BillingError",
50
+ "GatewayError",
51
+ "configure",
52
+ "check",
53
+ "wrap",
54
+ ]
55
+
56
+ __version__ = "0.1.0"
57
+
58
+ _DEFAULT_TIMEOUT_S = 35 # slightly above the gateway's 30 s request budget
59
+
60
+
61
+ # ── Results and errors ────────────────────────────────────────────────────────
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class Decision:
66
+ """The gateway's verdict for one tool call."""
67
+
68
+ allowed: bool
69
+ outcome: str # "ALLOW" | "DENY" | "STEP_UP"
70
+ reason: str = ""
71
+ rule_id: str = ""
72
+ receipt_id: str = ""
73
+ latency_ms: int = 0
74
+ raw: Dict[str, Any] = field(default_factory=dict, repr=False)
75
+
76
+
77
+ class ProsaderError(Exception):
78
+ """Base class for all SDK errors."""
79
+
80
+
81
+ class Denied(ProsaderError):
82
+ """Raised by wrapped functions when the gateway blocks the call."""
83
+
84
+ def __init__(self, decision: Decision):
85
+ self.decision = decision
86
+ msg = f"blocked by Prosader ({decision.outcome})"
87
+ if decision.rule_id:
88
+ msg += f" rule={decision.rule_id}"
89
+ if decision.reason:
90
+ msg += f": {decision.reason}"
91
+ super().__init__(msg)
92
+
93
+
94
+ class BillingError(ProsaderError):
95
+ """Raised on HTTP 402 — subscription inactive or decision quota exhausted."""
96
+
97
+ def __init__(self, message: str, status: str = "", code: str = ""):
98
+ self.status = status
99
+ self.code = code
100
+ super().__init__(message)
101
+
102
+
103
+ class GatewayError(ProsaderError):
104
+ """Raised when the gateway is unreachable or returns an unexpected response.
105
+
106
+ Prosader is fail-closed: treat this as a denial unless you have a
107
+ considered reason not to.
108
+ """
109
+
110
+
111
+ # ── Client ────────────────────────────────────────────────────────────────────
112
+
113
+
114
+ class Client:
115
+ """A connection to one Prosader gateway for one agent.
116
+
117
+ Args:
118
+ gateway_url: Base URL of the gateway mediator (e.g.
119
+ ``https://gateway.example.com:8443``). Falls back to the
120
+ ``PROSADER_GATEWAY_URL`` environment variable.
121
+ api_key: Prosader API key (``psk-...``). Falls back to
122
+ ``PROSADER_API_KEY``. Optional; without it the gateway attributes
123
+ activity to the default tenant.
124
+ agent_id: Stable identifier for this agent, shown in the dashboard
125
+ and stamped on every receipt. Falls back to ``PROSADER_AGENT_ID``.
126
+ session_id: Groups related calls into one session. A fresh ID is
127
+ generated per Client if omitted.
128
+ verify_tls: Set to ``False`` only for gateways using a self-signed
129
+ certificate in development.
130
+ timeout: Per-request timeout in seconds.
131
+ """
132
+
133
+ def __init__(
134
+ self,
135
+ gateway_url: Optional[str] = None,
136
+ api_key: Optional[str] = None,
137
+ agent_id: Optional[str] = None,
138
+ session_id: Optional[str] = None,
139
+ verify_tls: bool = True,
140
+ timeout: float = _DEFAULT_TIMEOUT_S,
141
+ ):
142
+ gateway_url = gateway_url or os.environ.get("PROSADER_GATEWAY_URL", "")
143
+ if not gateway_url:
144
+ raise ValueError(
145
+ "gateway_url is required (or set PROSADER_GATEWAY_URL)")
146
+ self.gateway_url = gateway_url.rstrip("/")
147
+ self.api_key = api_key or os.environ.get("PROSADER_API_KEY", "")
148
+ self.agent_id = agent_id or os.environ.get("PROSADER_AGENT_ID", "")
149
+ if not self.agent_id:
150
+ raise ValueError("agent_id is required (or set PROSADER_AGENT_ID)")
151
+ self.session_id = session_id or f"sdk-{uuid.uuid4().hex[:16]}"
152
+ self.timeout = timeout
153
+ self._ssl_context: Optional[ssl.SSLContext] = None
154
+ if not verify_tls:
155
+ self._ssl_context = ssl.create_default_context()
156
+ self._ssl_context.check_hostname = False
157
+ self._ssl_context.verify_mode = ssl.CERT_NONE
158
+
159
+ # ── Public API ────────────────────────────────────────────────────────────
160
+
161
+ def check(
162
+ self,
163
+ tool_name: str,
164
+ parameters: Optional[Dict[str, Any]] = None,
165
+ payload: Any = None,
166
+ ) -> Decision:
167
+ """Ask the gateway whether this tool call is allowed.
168
+
169
+ Sends a check-only request: the gateway evaluates policy and writes a
170
+ signed receipt but does not forward the call anywhere — executing the
171
+ tool remains the caller's responsibility.
172
+
173
+ Returns a :class:`Decision`. Raises :class:`BillingError` on quota or
174
+ subscription problems and :class:`GatewayError` when the gateway is
175
+ unreachable (fail closed: do not run the tool in that case).
176
+ """
177
+ body = {
178
+ "session_id": self.session_id,
179
+ "request_id": f"req-{uuid.uuid4().hex}",
180
+ "tool_name": tool_name,
181
+ "agent_id": self.agent_id,
182
+ "timestamp": datetime.now(timezone.utc).isoformat(),
183
+ }
184
+ if parameters:
185
+ body["parameters"] = {str(k): _stringify(v) for k, v in parameters.items()}
186
+ if payload is not None:
187
+ body["payload"] = payload
188
+
189
+ status, headers, resp = self._post_action(body)
190
+
191
+ if status == 402:
192
+ raise BillingError(
193
+ resp.get("error", "payment required"),
194
+ status=resp.get("status", ""),
195
+ code=resp.get("code", ""),
196
+ )
197
+ if status == 400:
198
+ raise ProsaderError(
199
+ f"gateway rejected request: {resp.get('error', 'bad request')}")
200
+ if status not in (200, 429):
201
+ raise GatewayError(f"unexpected gateway response: HTTP {status}")
202
+
203
+ outcome = headers.get("X-Prosader-Decision", "")
204
+ if not outcome:
205
+ outcome = "ALLOW" if resp.get("allowed") else "DENY"
206
+ try:
207
+ latency_ms = int(headers.get("X-Prosader-Latency-Ms", "0"))
208
+ except ValueError:
209
+ latency_ms = 0
210
+
211
+ return Decision(
212
+ allowed=bool(resp.get("allowed", False)),
213
+ outcome=outcome,
214
+ reason=resp.get("reason", ""),
215
+ rule_id=resp.get("rule_id", ""),
216
+ receipt_id=resp.get("receipt_id", headers.get("X-Prosader-Receipt-ID", "")),
217
+ latency_ms=latency_ms,
218
+ raw=resp,
219
+ )
220
+
221
+ def wrap(self, tool_name: Optional[str] = None) -> Callable:
222
+ """Decorator: check with the gateway before every call to the function.
223
+
224
+ The function's arguments are sent as the tool call's parameters so
225
+ policy rules can match on them. Blocked calls raise :class:`Denied`
226
+ instead of executing.
227
+
228
+ ::
229
+
230
+ @client.wrap("bash")
231
+ def run_bash(command): ...
232
+ """
233
+ def decorator(fn: Callable) -> Callable:
234
+ name = tool_name or fn.__name__
235
+ try:
236
+ sig = inspect.signature(fn)
237
+ except (TypeError, ValueError):
238
+ sig = None
239
+
240
+ @functools.wraps(fn)
241
+ def guarded(*args: Any, **kwargs: Any) -> Any:
242
+ params: Dict[str, Any] = {}
243
+ if sig is not None:
244
+ try:
245
+ bound = sig.bind(*args, **kwargs)
246
+ bound.apply_defaults()
247
+ params = dict(bound.arguments)
248
+ except TypeError:
249
+ params = dict(kwargs)
250
+ else:
251
+ params = dict(kwargs)
252
+
253
+ decision = self.check(name, parameters=params)
254
+ if not decision.allowed:
255
+ raise Denied(decision)
256
+ return fn(*args, **kwargs)
257
+
258
+ return guarded
259
+
260
+ return decorator
261
+
262
+ # ── Internals ─────────────────────────────────────────────────────────────
263
+
264
+ def _post_action(self, body: Dict[str, Any]):
265
+ """POST the action to the gateway; returns (status, headers, json_body)."""
266
+ data = json.dumps(body).encode("utf-8")
267
+ req = urllib.request.Request(
268
+ self.gateway_url + "/v1/action",
269
+ data=data,
270
+ method="POST",
271
+ headers={
272
+ "Content-Type": "application/json",
273
+ "X-Prosader-Check-Only": "true",
274
+ "User-Agent": f"prosader-python/{__version__}",
275
+ },
276
+ )
277
+ if self.api_key:
278
+ req.add_header("Authorization", f"Bearer {self.api_key}")
279
+
280
+ try:
281
+ with urllib.request.urlopen(
282
+ req, timeout=self.timeout, context=self._ssl_context
283
+ ) as r:
284
+ return r.status, dict(r.headers), _parse_json(r.read())
285
+ except urllib.error.HTTPError as e:
286
+ # Non-2xx responses (400/402/429/...) still carry a JSON body.
287
+ with e:
288
+ return e.code, dict(e.headers), _parse_json(e.read())
289
+ except (urllib.error.URLError, OSError, TimeoutError) as e:
290
+ raise GatewayError(f"gateway unreachable: {e}") from e
291
+
292
+
293
+ def _stringify(v: Any) -> str:
294
+ """Render a parameter value the way policy rules expect to match it."""
295
+ if isinstance(v, str):
296
+ return v
297
+ try:
298
+ return json.dumps(v)
299
+ except (TypeError, ValueError):
300
+ return str(v)
301
+
302
+
303
+ def _parse_json(raw: bytes) -> Dict[str, Any]:
304
+ try:
305
+ parsed = json.loads(raw)
306
+ return parsed if isinstance(parsed, dict) else {}
307
+ except (json.JSONDecodeError, UnicodeDecodeError):
308
+ return {}
309
+
310
+
311
+ # ── Module-level convenience (single global client) ───────────────────────────
312
+
313
+ _default_client: Optional[Client] = None
314
+
315
+
316
+ def configure(**kwargs: Any) -> Client:
317
+ """Create and install the module-level default client.
318
+
319
+ Accepts the same arguments as :class:`Client`. After calling this,
320
+ :func:`prosader.check` and :func:`prosader.wrap` use the configured
321
+ client.
322
+ """
323
+ global _default_client
324
+ _default_client = Client(**kwargs)
325
+ return _default_client
326
+
327
+
328
+ def _require_default() -> Client:
329
+ global _default_client
330
+ if _default_client is None:
331
+ # Attempt implicit configuration from environment variables.
332
+ _default_client = Client()
333
+ return _default_client
334
+
335
+
336
+ def check(
337
+ tool_name: str,
338
+ parameters: Optional[Dict[str, Any]] = None,
339
+ payload: Any = None,
340
+ ) -> Decision:
341
+ """Module-level :meth:`Client.check` using the default client."""
342
+ return _require_default().check(tool_name, parameters=parameters, payload=payload)
343
+
344
+
345
+ def wrap(tool_name: Optional[str] = None) -> Callable:
346
+ """Module-level :meth:`Client.wrap` using the default client.
347
+
348
+ The client is resolved at call time, so ``@prosader.wrap()`` can decorate
349
+ functions before :func:`configure` runs.
350
+ """
351
+ def decorator(fn: Callable) -> Callable:
352
+ try:
353
+ sig = inspect.signature(fn)
354
+ except (TypeError, ValueError):
355
+ sig = None
356
+ name = tool_name or fn.__name__
357
+
358
+ @functools.wraps(fn)
359
+ def guarded(*args: Any, **kwargs: Any) -> Any:
360
+ client = _require_default()
361
+ params: Dict[str, Any] = {}
362
+ if sig is not None:
363
+ try:
364
+ bound = sig.bind(*args, **kwargs)
365
+ bound.apply_defaults()
366
+ params = dict(bound.arguments)
367
+ except TypeError:
368
+ params = dict(kwargs)
369
+ else:
370
+ params = dict(kwargs)
371
+ decision = client.check(name, parameters=params)
372
+ if not decision.allowed:
373
+ raise Denied(decision)
374
+ return fn(*args, **kwargs)
375
+
376
+ return guarded
377
+
378
+ return decorator
@@ -0,0 +1,102 @@
1
+ Metadata-Version: 2.4
2
+ Name: prosader
3
+ Version: 0.1.0
4
+ Summary: Guard AI agent tool calls with a Prosader runtime security gateway
5
+ Author: Prosader
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://prosader.com
8
+ Keywords: ai-agents,security,audit,compliance,mcp
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Security
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+
16
+ # Prosader Python SDK
17
+
18
+ Guard AI agent tool calls with a [Prosader](https://prosader.com) runtime
19
+ security gateway. Every guarded call is evaluated against your policy and
20
+ produces a signed, tamper-evident audit receipt — evidence you can hand to an
21
+ auditor and verify independently with `prosader-verify`.
22
+
23
+ No dependencies: the SDK uses only the Python standard library (3.9+).
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pip install prosader
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ ```python
34
+ import prosader
35
+
36
+ client = prosader.Client(
37
+ gateway_url="https://gateway.example.com:8443",
38
+ api_key="psk-live-...",
39
+ agent_id="billing-agent",
40
+ )
41
+
42
+ # Option 1 — explicit check before you execute a tool:
43
+ decision = client.check("bash", parameters={"command": "rm -rf /tmp/cache"})
44
+ if decision.allowed:
45
+ run_command(...)
46
+ else:
47
+ print(f"blocked by rule {decision.rule_id}: {decision.reason}")
48
+ # decision.receipt_id — signed audit receipt either way
49
+
50
+ # Option 2 — wrap a function; blocked calls raise prosader.Denied:
51
+ @client.wrap("send_email")
52
+ def send_email(to, subject, body):
53
+ ...
54
+
55
+ try:
56
+ send_email(to="cfo@example.com", subject="Invoice", body="...")
57
+ except prosader.Denied as e:
58
+ print(e.decision.reason)
59
+ ```
60
+
61
+ Or configure once and use module-level helpers:
62
+
63
+ ```python
64
+ prosader.configure(gateway_url="https://...", api_key="psk-...", agent_id="my-agent")
65
+
66
+ @prosader.wrap("bash")
67
+ def run_bash(command): ...
68
+ ```
69
+
70
+ Configuration falls back to environment variables: `PROSADER_GATEWAY_URL`,
71
+ `PROSADER_API_KEY`, `PROSADER_AGENT_ID`.
72
+
73
+ ## Outcomes
74
+
75
+ | Outcome | `decision.allowed` | Meaning |
76
+ |-----------|--------------------|----------------------------------------------------|
77
+ | `ALLOW` | `True` | Proceed. |
78
+ | `DENY` | `False` | Blocked by policy (`rule_id`, `reason` populated). |
79
+ | `STEP_UP` | `False` | Held for human approval in the Prosader dashboard. |
80
+
81
+ Errors:
82
+
83
+ - `prosader.Denied` — raised by wrapped functions when blocked.
84
+ - `prosader.BillingError` — subscription inactive or decision quota exhausted (HTTP 402).
85
+ - `prosader.GatewayError` — gateway unreachable. Prosader is fail-closed:
86
+ treat this as a denial.
87
+
88
+ ## Notes
89
+
90
+ - Function arguments of wrapped functions are sent as the tool call's
91
+ `parameters` so policy rules can match on them (e.g. deny `bash` where
92
+ `command` contains `rm -rf`).
93
+ - For development gateways with a self-signed certificate, pass
94
+ `verify_tls=False`. Never do this in production.
95
+ - Requires a gateway new enough to support check-only mode
96
+ (`X-Prosader-Check-Only`), July 2026 or later.
97
+
98
+ ## Running tests
99
+
100
+ ```bash
101
+ python -m unittest discover -s tests -v
102
+ ```
@@ -0,0 +1,5 @@
1
+ prosader/__init__.py,sha256=paxeiWzp9hBpTqQO3gDh9PPLHy6fDxumzXVtXI-mlcE,13278
2
+ prosader-0.1.0.dist-info/METADATA,sha256=cXpjWh2RSj5nm6L0arDeavrDR9kkV9Hjt9Bmkp9m0HU,3201
3
+ prosader-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
4
+ prosader-0.1.0.dist-info/top_level.txt,sha256=l1ncwE0n8ZgCBMsHBzkzlKmkZnnur0SR1uE4km8pMyw,9
5
+ prosader-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ prosader