prosader 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,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,87 @@
1
+ # Prosader Python SDK
2
+
3
+ Guard AI agent tool calls with a [Prosader](https://prosader.com) runtime
4
+ security gateway. Every guarded call is evaluated against your policy and
5
+ produces a signed, tamper-evident audit receipt — evidence you can hand to an
6
+ auditor and verify independently with `prosader-verify`.
7
+
8
+ No dependencies: the SDK uses only the Python standard library (3.9+).
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pip install prosader
14
+ ```
15
+
16
+ ## Usage
17
+
18
+ ```python
19
+ import prosader
20
+
21
+ client = prosader.Client(
22
+ gateway_url="https://gateway.example.com:8443",
23
+ api_key="psk-live-...",
24
+ agent_id="billing-agent",
25
+ )
26
+
27
+ # Option 1 — explicit check before you execute a tool:
28
+ decision = client.check("bash", parameters={"command": "rm -rf /tmp/cache"})
29
+ if decision.allowed:
30
+ run_command(...)
31
+ else:
32
+ print(f"blocked by rule {decision.rule_id}: {decision.reason}")
33
+ # decision.receipt_id — signed audit receipt either way
34
+
35
+ # Option 2 — wrap a function; blocked calls raise prosader.Denied:
36
+ @client.wrap("send_email")
37
+ def send_email(to, subject, body):
38
+ ...
39
+
40
+ try:
41
+ send_email(to="cfo@example.com", subject="Invoice", body="...")
42
+ except prosader.Denied as e:
43
+ print(e.decision.reason)
44
+ ```
45
+
46
+ Or configure once and use module-level helpers:
47
+
48
+ ```python
49
+ prosader.configure(gateway_url="https://...", api_key="psk-...", agent_id="my-agent")
50
+
51
+ @prosader.wrap("bash")
52
+ def run_bash(command): ...
53
+ ```
54
+
55
+ Configuration falls back to environment variables: `PROSADER_GATEWAY_URL`,
56
+ `PROSADER_API_KEY`, `PROSADER_AGENT_ID`.
57
+
58
+ ## Outcomes
59
+
60
+ | Outcome | `decision.allowed` | Meaning |
61
+ |-----------|--------------------|----------------------------------------------------|
62
+ | `ALLOW` | `True` | Proceed. |
63
+ | `DENY` | `False` | Blocked by policy (`rule_id`, `reason` populated). |
64
+ | `STEP_UP` | `False` | Held for human approval in the Prosader dashboard. |
65
+
66
+ Errors:
67
+
68
+ - `prosader.Denied` — raised by wrapped functions when blocked.
69
+ - `prosader.BillingError` — subscription inactive or decision quota exhausted (HTTP 402).
70
+ - `prosader.GatewayError` — gateway unreachable. Prosader is fail-closed:
71
+ treat this as a denial.
72
+
73
+ ## Notes
74
+
75
+ - Function arguments of wrapped functions are sent as the tool call's
76
+ `parameters` so policy rules can match on them (e.g. deny `bash` where
77
+ `command` contains `rm -rf`).
78
+ - For development gateways with a self-signed certificate, pass
79
+ `verify_tls=False`. Never do this in production.
80
+ - Requires a gateway new enough to support check-only mode
81
+ (`X-Prosader-Check-Only`), July 2026 or later.
82
+
83
+ ## Running tests
84
+
85
+ ```bash
86
+ python -m unittest discover -s tests -v
87
+ ```
@@ -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,8 @@
1
+ README.md
2
+ pyproject.toml
3
+ prosader/__init__.py
4
+ prosader.egg-info/PKG-INFO
5
+ prosader.egg-info/SOURCES.txt
6
+ prosader.egg-info/dependency_links.txt
7
+ prosader.egg-info/top_level.txt
8
+ tests/test_client.py
@@ -0,0 +1 @@
1
+ prosader
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "prosader"
7
+ version = "0.1.0"
8
+ description = "Guard AI agent tool calls with a Prosader runtime security gateway"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "Apache-2.0" }
12
+ authors = [{ name = "Prosader" }]
13
+ keywords = ["ai-agents", "security", "audit", "compliance", "mcp"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Topic :: Security",
19
+ ]
20
+ # Intentionally no runtime dependencies — standard library only.
21
+ dependencies = []
22
+
23
+ [project.urls]
24
+ Homepage = "https://prosader.com"
25
+
26
+ [tool.setuptools.packages.find]
27
+ include = ["prosader*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,196 @@
1
+ """Tests for the Prosader Python SDK against a stub gateway.
2
+
3
+ Run from sdk/python/: python -m unittest discover -s tests -v
4
+ """
5
+
6
+ import json
7
+ import os
8
+ import sys
9
+ import threading
10
+ import unittest
11
+ from http.server import BaseHTTPRequestHandler, HTTPServer
12
+
13
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
14
+
15
+ import prosader # noqa: E402
16
+
17
+
18
+ class StubGateway(BaseHTTPRequestHandler):
19
+ """Minimal stand-in for POST /v1/action.
20
+
21
+ Behaviour is driven by the requested tool_name:
22
+ allow_tool → 200 allowed:true
23
+ deny_tool → 200 allowed:false (DENY)
24
+ stepup_tool → 200 allowed:false (STEP_UP)
25
+ billing_tool → 402
26
+ bad_tool → 400
27
+ """
28
+
29
+ received = [] # (headers, body) of every request, newest last
30
+
31
+ def do_POST(self): # noqa: N802
32
+ assert self.path == "/v1/action"
33
+ raw = self.rfile.read(int(self.headers.get("Content-Length", "0")))
34
+ body = json.loads(raw)
35
+ StubGateway.received.append((dict(self.headers), body))
36
+ tool = body.get("tool_name", "")
37
+
38
+ if tool == "billing_tool":
39
+ self._json(402, {"error": "monthly decision limit of 100 reached",
40
+ "status": "limit_exceeded",
41
+ "code": "DECISION_LIMIT_EXCEEDED"})
42
+ return
43
+ if tool == "bad_tool":
44
+ self._json(400, {"error": "missing required field: agent_id",
45
+ "code": "VALIDATION_ERROR"})
46
+ return
47
+
48
+ if tool == "deny_tool":
49
+ outcome, resp = "DENY", {"allowed": False,
50
+ "reason": "forbidden by policy",
51
+ "rule_id": "FA-001",
52
+ "receipt_id": "rcpt-deny"}
53
+ elif tool == "stepup_tool":
54
+ outcome, resp = "STEP_UP", {"allowed": False,
55
+ "reason": "held for human approval",
56
+ "rule_id": "FA-007",
57
+ "receipt_id": "rcpt-stepup"}
58
+ else:
59
+ outcome, resp = "ALLOW", {"allowed": True, "receipt_id": "rcpt-allow"}
60
+
61
+ self._json(200, resp, extra={
62
+ "X-Prosader-Decision": outcome,
63
+ "X-Prosader-Receipt-ID": resp["receipt_id"],
64
+ "X-Prosader-Latency-Ms": "3",
65
+ })
66
+
67
+ def _json(self, status, obj, extra=None):
68
+ data = json.dumps(obj).encode()
69
+ self.send_response(status)
70
+ self.send_header("Content-Type", "application/json")
71
+ self.send_header("Content-Length", str(len(data)))
72
+ for k, v in (extra or {}).items():
73
+ self.send_header(k, v)
74
+ self.end_headers()
75
+ self.wfile.write(data)
76
+
77
+ def log_message(self, *args): # silence test output
78
+ pass
79
+
80
+
81
+ class SDKTest(unittest.TestCase):
82
+ @classmethod
83
+ def setUpClass(cls):
84
+ cls.server = HTTPServer(("127.0.0.1", 0), StubGateway)
85
+ threading.Thread(target=cls.server.serve_forever, daemon=True).start()
86
+ cls.url = f"http://127.0.0.1:{cls.server.server_port}"
87
+
88
+ @classmethod
89
+ def tearDownClass(cls):
90
+ cls.server.shutdown()
91
+
92
+ def setUp(self):
93
+ StubGateway.received.clear()
94
+ self.client = prosader.Client(
95
+ gateway_url=self.url, api_key="psk-test-123", agent_id="agent-test")
96
+
97
+ # ── check() ───────────────────────────────────────────────────────────────
98
+
99
+ def test_allow(self):
100
+ d = self.client.check("allow_tool", parameters={"path": "/tmp/x"})
101
+ self.assertTrue(d.allowed)
102
+ self.assertEqual(d.outcome, "ALLOW")
103
+ self.assertEqual(d.receipt_id, "rcpt-allow")
104
+ self.assertEqual(d.latency_ms, 3)
105
+
106
+ def test_deny(self):
107
+ d = self.client.check("deny_tool")
108
+ self.assertFalse(d.allowed)
109
+ self.assertEqual(d.outcome, "DENY")
110
+ self.assertEqual(d.rule_id, "FA-001")
111
+ self.assertEqual(d.reason, "forbidden by policy")
112
+
113
+ def test_step_up(self):
114
+ d = self.client.check("stepup_tool")
115
+ self.assertFalse(d.allowed)
116
+ self.assertEqual(d.outcome, "STEP_UP")
117
+
118
+ def test_billing_error(self):
119
+ with self.assertRaises(prosader.BillingError) as ctx:
120
+ self.client.check("billing_tool")
121
+ self.assertEqual(ctx.exception.code, "DECISION_LIMIT_EXCEEDED")
122
+
123
+ def test_bad_request(self):
124
+ with self.assertRaises(prosader.ProsaderError):
125
+ self.client.check("bad_tool")
126
+
127
+ def test_gateway_unreachable_raises(self):
128
+ dead = prosader.Client(
129
+ gateway_url="http://127.0.0.1:1", agent_id="agent-test", timeout=0.5)
130
+ with self.assertRaises(prosader.GatewayError):
131
+ dead.check("allow_tool")
132
+
133
+ def test_request_wire_format(self):
134
+ self.client.check("allow_tool", parameters={"n": 3, "cmd": "ls"})
135
+ headers, body = StubGateway.received[-1]
136
+ self.assertEqual(headers.get("X-Prosader-Check-Only"), "true")
137
+ self.assertEqual(headers.get("Authorization"), "Bearer psk-test-123")
138
+ self.assertEqual(body["agent_id"], "agent-test")
139
+ self.assertEqual(body["session_id"], self.client.session_id)
140
+ self.assertTrue(body["request_id"])
141
+ self.assertTrue(body["timestamp"])
142
+ # Non-string parameter values are stringified for rule matching.
143
+ self.assertEqual(body["parameters"], {"n": "3", "cmd": "ls"})
144
+
145
+ # ── wrap() ────────────────────────────────────────────────────────────────
146
+
147
+ def test_wrap_allows_and_passes_through(self):
148
+ @self.client.wrap("allow_tool")
149
+ def add(a, b=10):
150
+ return a + b
151
+
152
+ self.assertEqual(add(5), 15)
153
+ _, body = StubGateway.received[-1]
154
+ # Bound arguments (including defaults) become parameters.
155
+ self.assertEqual(body["parameters"], {"a": "5", "b": "10"})
156
+
157
+ def test_wrap_denied_raises_without_executing(self):
158
+ calls = []
159
+
160
+ @self.client.wrap("deny_tool")
161
+ def dangerous():
162
+ calls.append(1)
163
+
164
+ with self.assertRaises(prosader.Denied) as ctx:
165
+ dangerous()
166
+ self.assertEqual(calls, [])
167
+ self.assertEqual(ctx.exception.decision.rule_id, "FA-001")
168
+
169
+ def test_wrap_defaults_tool_name_to_function_name(self):
170
+ @self.client.wrap()
171
+ def allow_tool():
172
+ return "ran"
173
+
174
+ self.assertEqual(allow_tool(), "ran")
175
+ _, body = StubGateway.received[-1]
176
+ self.assertEqual(body["tool_name"], "allow_tool")
177
+
178
+ # ── module-level API ──────────────────────────────────────────────────────
179
+
180
+ def test_module_level_configure_and_wrap(self):
181
+ prosader.configure(
182
+ gateway_url=self.url, api_key="psk-test-123", agent_id="agent-mod")
183
+
184
+ @prosader.wrap("allow_tool")
185
+ def do_thing(x):
186
+ return x * 2
187
+
188
+ self.assertEqual(do_thing(4), 8)
189
+ _, body = StubGateway.received[-1]
190
+ self.assertEqual(body["agent_id"], "agent-mod")
191
+
192
+ self.assertTrue(prosader.check("allow_tool").allowed)
193
+
194
+
195
+ if __name__ == "__main__":
196
+ unittest.main()