sphyr-sdk 2.0.0b1__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.
- sphyr_sdk-2.0.0b1/PKG-INFO +152 -0
- sphyr_sdk-2.0.0b1/README.md +134 -0
- sphyr_sdk-2.0.0b1/pyproject.toml +53 -0
- sphyr_sdk-2.0.0b1/setup.cfg +4 -0
- sphyr_sdk-2.0.0b1/sphyr_sdk/__init__.py +150 -0
- sphyr_sdk-2.0.0b1/sphyr_sdk/__main__.py +70 -0
- sphyr_sdk-2.0.0b1/sphyr_sdk/_connectivity.py +207 -0
- sphyr_sdk-2.0.0b1/sphyr_sdk/_instrument.py +476 -0
- sphyr_sdk-2.0.0b1/sphyr_sdk/_login.py +191 -0
- sphyr_sdk-2.0.0b1/sphyr_sdk/_session_models.py +46 -0
- sphyr_sdk-2.0.0b1/sphyr_sdk/_signing.py +97 -0
- sphyr_sdk-2.0.0b1/sphyr_sdk/_tool_drift.py +329 -0
- sphyr_sdk-2.0.0b1/sphyr_sdk/_url_guard.py +91 -0
- sphyr_sdk-2.0.0b1/sphyr_sdk/client.py +541 -0
- sphyr_sdk-2.0.0b1/sphyr_sdk/errors.py +316 -0
- sphyr_sdk-2.0.0b1/sphyr_sdk/generated_models.py +71 -0
- sphyr_sdk-2.0.0b1/sphyr_sdk/idempotency_fingerprint.py +75 -0
- sphyr_sdk-2.0.0b1/sphyr_sdk/retry_policy.py +172 -0
- sphyr_sdk-2.0.0b1/sphyr_sdk/verify.py +97 -0
- sphyr_sdk-2.0.0b1/sphyr_sdk.egg-info/PKG-INFO +152 -0
- sphyr_sdk-2.0.0b1/sphyr_sdk.egg-info/SOURCES.txt +37 -0
- sphyr_sdk-2.0.0b1/sphyr_sdk.egg-info/dependency_links.txt +1 -0
- sphyr_sdk-2.0.0b1/sphyr_sdk.egg-info/requires.txt +13 -0
- sphyr_sdk-2.0.0b1/sphyr_sdk.egg-info/top_level.txt +1 -0
- sphyr_sdk-2.0.0b1/tests/test_client.py +247 -0
- sphyr_sdk-2.0.0b1/tests/test_connectivity.py +160 -0
- sphyr_sdk-2.0.0b1/tests/test_credentials.py +122 -0
- sphyr_sdk-2.0.0b1/tests/test_degraded.py +235 -0
- sphyr_sdk-2.0.0b1/tests/test_errors.py +357 -0
- sphyr_sdk-2.0.0b1/tests/test_handshake_parity.py +74 -0
- sphyr_sdk-2.0.0b1/tests/test_idempotency_fingerprint.py +135 -0
- sphyr_sdk-2.0.0b1/tests/test_instrument.py +511 -0
- sphyr_sdk-2.0.0b1/tests/test_login.py +345 -0
- sphyr_sdk-2.0.0b1/tests/test_main.py +58 -0
- sphyr_sdk-2.0.0b1/tests/test_parity.py +246 -0
- sphyr_sdk-2.0.0b1/tests/test_session_models.py +96 -0
- sphyr_sdk-2.0.0b1/tests/test_tool_drift.py +742 -0
- sphyr_sdk-2.0.0b1/tests/test_url_guard.py +137 -0
- sphyr_sdk-2.0.0b1/tests/test_verify.py +167 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sphyr-sdk
|
|
3
|
+
Version: 2.0.0b1
|
|
4
|
+
Summary: Python SDK for Sphyr Agent Guard
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: httpx<1.0,>=0.28.0
|
|
8
|
+
Requires-Dist: pydantic<3.0,>=2.13.0
|
|
9
|
+
Requires-Dist: cryptography<49.0,>=42.0
|
|
10
|
+
Requires-Dist: requests<3.0,>=2.32
|
|
11
|
+
Provides-Extra: instrument
|
|
12
|
+
Requires-Dist: requests<3.0,>=2.32; extra == "instrument"
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest-cov<8.0,>=7.0; extra == "dev"
|
|
15
|
+
Requires-Dist: freezegun<2.0,>=1.5; extra == "dev"
|
|
16
|
+
Requires-Dist: pytest-asyncio<2.0,>=0.25; extra == "dev"
|
|
17
|
+
Requires-Dist: requests<3.0,>=2.32; extra == "dev"
|
|
18
|
+
|
|
19
|
+
# sphyr-sdk
|
|
20
|
+
|
|
21
|
+
Python SDK for [Sphyr Agent Guard](https://sphyr.io). Adds verifiable, auditable security checks to every outbound agent request.
|
|
22
|
+
|
|
23
|
+
## Quick-Start
|
|
24
|
+
|
|
25
|
+
### 1. Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install sphyr-sdk
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### 2. Sign in (no key copy-paste)
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npx @sphyr/cli login
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Opens your browser, signs you in via the device flow, and writes your credential to
|
|
38
|
+
`~/.sphyr/config.json` (owner-only `0600`). After this, the SDK picks it up automatically.
|
|
39
|
+
|
|
40
|
+
> No Node/npx on your machine? Run `python -m sphyr_sdk login` for the same device-flow sign-in.
|
|
41
|
+
>
|
|
42
|
+
> Setting up an IDE-based agent (Claude Desktop, Cursor, etc.) instead? Use
|
|
43
|
+
> `npx @sphyr/cli guard init` — it signs you in **and** writes the MCP server config for each
|
|
44
|
+
> detected IDE.
|
|
45
|
+
|
|
46
|
+
### 3. Protect every outbound call (recommended — install and forget)
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from sphyr_sdk import auto_instrument
|
|
50
|
+
|
|
51
|
+
# One line at startup. After this, every requests / httpx call is signed, scanned,
|
|
52
|
+
# and audited through Sphyr automatically — no per-call wrapping.
|
|
53
|
+
report = auto_instrument()
|
|
54
|
+
print(report.requests.status) # 'patched' or 'not_installed'
|
|
55
|
+
print(report.httpx.status) # 'patched' or 'not_installed'
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
> **Breaking change (v0.27.0):** `auto_instrument()` now returns a `PatchReport` (per-lib
|
|
59
|
+
> instrumentation status) instead of a `SphyrClient`. Callers that did
|
|
60
|
+
> `client = auto_instrument(...)` must update — use `SphyrClient(...).instrument()` directly
|
|
61
|
+
> if you need a client reference for later teardown.
|
|
62
|
+
>
|
|
63
|
+
> **The SDK does nothing until you call `auto_instrument()`** — importing the package alone is not protective.
|
|
64
|
+
>
|
|
65
|
+
> **Fail-closed by default:** if the Sphyr gateway is unreachable, instrumented requests raise
|
|
66
|
+
> `SphyrNetworkError` rather than leaving unscreened. Pass `auto_instrument(fail_closed=False)` to
|
|
67
|
+
> prioritize app uptime over strict enforcement.
|
|
68
|
+
|
|
69
|
+
Credentials resolve in order: **explicit args → `SPHYR_CREDENTIAL` env var →
|
|
70
|
+
`~/.sphyr/config.json`**. So `auto_instrument()` with no arguments works after `npx @sphyr/cli login`.
|
|
71
|
+
|
|
72
|
+
### Works with your agent framework — automatically
|
|
73
|
+
|
|
74
|
+
`auto_instrument()` patches the underlying HTTP layer (`requests` and `httpx`), so any library or
|
|
75
|
+
framework that makes outbound calls through them is signed, scanned, and audited the moment you call
|
|
76
|
+
it — **no adapters, no per-framework wiring**. That includes the OpenAI SDK, the Anthropic SDK,
|
|
77
|
+
LangChain / LangGraph, CrewAI, AutoGen, and LlamaIndex (all of which use `requests`/`httpx` under the
|
|
78
|
+
hood). Call `auto_instrument()` once at process start, before your agent makes its first request.
|
|
79
|
+
|
|
80
|
+
### Manual / advanced — wrap calls yourself
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
import asyncio
|
|
84
|
+
|
|
85
|
+
from sphyr_sdk.client import AsyncSphyrClient
|
|
86
|
+
from sphyr_sdk.errors import SphyrError
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def main() -> None:
|
|
90
|
+
client = AsyncSphyrClient() # resolves creds from env / ~/.sphyr/config.json
|
|
91
|
+
try:
|
|
92
|
+
result = await client.call({"url": "https://api.example.com/data", "mthd": "GET"})
|
|
93
|
+
print(result)
|
|
94
|
+
except SphyrError as err:
|
|
95
|
+
print(err.code, err.retryable, err.docs_url)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
asyncio.run(main())
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### 4. Add the MCP Server (Claude Desktop, Cursor, etc.)
|
|
102
|
+
|
|
103
|
+
**Recommended — one command:**
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
npx @sphyr/cli guard init
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
This opens your browser, signs you in, and writes per-IDE configs automatically — including the `SPHYR_CREDENTIAL` env var. MCP server setup uses the Node-based `sphyr-mcp` binary from `@sphyr/sdk`.
|
|
110
|
+
|
|
111
|
+
See [sphyr.io/docs/mcp](https://sphyr.io/docs/mcp) for per-client setup (Claude Desktop, Claude Code CLI, Cursor, Antigravity, OpenAI Codex).
|
|
112
|
+
|
|
113
|
+
Full reference (retry policy, custom transport, timeout config, error catalog): [sphyr.io/docs](https://sphyr.io/docs)
|
|
114
|
+
|
|
115
|
+
## What traffic is intercepted (and what is not)
|
|
116
|
+
|
|
117
|
+
`instrument()` / `auto_instrument()` work by patching specific HTTP client libraries. Traffic from anything else flows **directly to the network, unguarded** — there is no OS-level interception.
|
|
118
|
+
|
|
119
|
+
| Transport | Intercepted? |
|
|
120
|
+
|---|---|
|
|
121
|
+
| `requests` (Session-based and module-level) | ✅ yes |
|
|
122
|
+
| `httpx` (`Client` and `AsyncClient`) | ✅ yes |
|
|
123
|
+
| `aiohttp` | ❌ no — flows unguarded |
|
|
124
|
+
| `urllib` / `urllib.request` / `http.client` | ❌ no — flows unguarded |
|
|
125
|
+
| `urllib3` (used directly, not via requests) | ❌ no — flows unguarded |
|
|
126
|
+
| `pycurl` / subprocess `curl` | ❌ no — flows unguarded |
|
|
127
|
+
|
|
128
|
+
If your agent uses an unsupported transport, route those calls through `SphyrClient.call()` explicitly, or switch the agent's HTTP layer to `requests`/`httpx`.
|
|
129
|
+
|
|
130
|
+
**Local/private traffic exclusions.** To prevent gateway loops and keep local development working, intercepted clients pass traffic to `localhost`, RFC 1918 ranges (`10/8`, `172.16/12`, `192.168/16`), generic link-local (`169.254/16`), and IPv6 ULA/link-local **directly to the real transport, unguarded**. One deliberate exception: the cloud metadata endpoints (`169.254.169.254`, `fd00:ec2::254`) are always routed through the gateway, which blocks and audits the attempt — these are the primary SSRF targets a prompt-injected agent probes.
|
|
131
|
+
|
|
132
|
+
## Tool Schema Drift Detection
|
|
133
|
+
|
|
134
|
+
When `SphyrClient.instrument()` is active, the SDK automatically detects changes to an MCP server's `tools/list` response between sessions. The first response per origin sets the baseline; subsequent changes raise `SphyrDriftError` (in `BLOCK` mode) or emit a telemetry event and continue (in `LOG` mode, the default).
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from sphyr_sdk import SphyrClient, SphyrDriftError
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
result = await client.call({"url": "https://mcp.example.com/...", "mthd": "GET"})
|
|
141
|
+
except SphyrDriftError as err:
|
|
142
|
+
# Tool schema changed unexpectedly — halt and alert
|
|
143
|
+
print(err.code) # "TOOL_SCHEMA_DRIFT"
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
`SphyrDriftError` is also importable from `sdk.agent_guard_utils` for legacy integrations. The enforcement mode (`LOG` or `BLOCK`) is set per API key via `key_flags.tool_drift_mode` in the admin console.
|
|
147
|
+
|
|
148
|
+
## User-Agent header (custom integrations)
|
|
149
|
+
|
|
150
|
+
This SDK sends `User-Agent: sphyr-sdk-python/<version>` on every request to the Sphyr gateway. You do not need to do anything to enable this — it is set automatically.
|
|
151
|
+
|
|
152
|
+
If you are **building a custom MCP client** that talks to the Sphyr gateway directly (i.e. not using this SDK), you must set a `User-Agent` header on your requests. Cloudflare's WAF blocks Python `urllib`'s default `Python-urllib/3.X` User-Agent as a bot — requests fail with HTTP 403 / Error 1010 (`browser_signature_banned`) before they reach the Worker. Any non-empty descriptive value (e.g. `your-app/1.0`) is sufficient.
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# sphyr-sdk
|
|
2
|
+
|
|
3
|
+
Python SDK for [Sphyr Agent Guard](https://sphyr.io). Adds verifiable, auditable security checks to every outbound agent request.
|
|
4
|
+
|
|
5
|
+
## Quick-Start
|
|
6
|
+
|
|
7
|
+
### 1. Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install sphyr-sdk
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
### 2. Sign in (no key copy-paste)
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx @sphyr/cli login
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Opens your browser, signs you in via the device flow, and writes your credential to
|
|
20
|
+
`~/.sphyr/config.json` (owner-only `0600`). After this, the SDK picks it up automatically.
|
|
21
|
+
|
|
22
|
+
> No Node/npx on your machine? Run `python -m sphyr_sdk login` for the same device-flow sign-in.
|
|
23
|
+
>
|
|
24
|
+
> Setting up an IDE-based agent (Claude Desktop, Cursor, etc.) instead? Use
|
|
25
|
+
> `npx @sphyr/cli guard init` — it signs you in **and** writes the MCP server config for each
|
|
26
|
+
> detected IDE.
|
|
27
|
+
|
|
28
|
+
### 3. Protect every outbound call (recommended — install and forget)
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from sphyr_sdk import auto_instrument
|
|
32
|
+
|
|
33
|
+
# One line at startup. After this, every requests / httpx call is signed, scanned,
|
|
34
|
+
# and audited through Sphyr automatically — no per-call wrapping.
|
|
35
|
+
report = auto_instrument()
|
|
36
|
+
print(report.requests.status) # 'patched' or 'not_installed'
|
|
37
|
+
print(report.httpx.status) # 'patched' or 'not_installed'
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
> **Breaking change (v0.27.0):** `auto_instrument()` now returns a `PatchReport` (per-lib
|
|
41
|
+
> instrumentation status) instead of a `SphyrClient`. Callers that did
|
|
42
|
+
> `client = auto_instrument(...)` must update — use `SphyrClient(...).instrument()` directly
|
|
43
|
+
> if you need a client reference for later teardown.
|
|
44
|
+
>
|
|
45
|
+
> **The SDK does nothing until you call `auto_instrument()`** — importing the package alone is not protective.
|
|
46
|
+
>
|
|
47
|
+
> **Fail-closed by default:** if the Sphyr gateway is unreachable, instrumented requests raise
|
|
48
|
+
> `SphyrNetworkError` rather than leaving unscreened. Pass `auto_instrument(fail_closed=False)` to
|
|
49
|
+
> prioritize app uptime over strict enforcement.
|
|
50
|
+
|
|
51
|
+
Credentials resolve in order: **explicit args → `SPHYR_CREDENTIAL` env var →
|
|
52
|
+
`~/.sphyr/config.json`**. So `auto_instrument()` with no arguments works after `npx @sphyr/cli login`.
|
|
53
|
+
|
|
54
|
+
### Works with your agent framework — automatically
|
|
55
|
+
|
|
56
|
+
`auto_instrument()` patches the underlying HTTP layer (`requests` and `httpx`), so any library or
|
|
57
|
+
framework that makes outbound calls through them is signed, scanned, and audited the moment you call
|
|
58
|
+
it — **no adapters, no per-framework wiring**. That includes the OpenAI SDK, the Anthropic SDK,
|
|
59
|
+
LangChain / LangGraph, CrewAI, AutoGen, and LlamaIndex (all of which use `requests`/`httpx` under the
|
|
60
|
+
hood). Call `auto_instrument()` once at process start, before your agent makes its first request.
|
|
61
|
+
|
|
62
|
+
### Manual / advanced — wrap calls yourself
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
import asyncio
|
|
66
|
+
|
|
67
|
+
from sphyr_sdk.client import AsyncSphyrClient
|
|
68
|
+
from sphyr_sdk.errors import SphyrError
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def main() -> None:
|
|
72
|
+
client = AsyncSphyrClient() # resolves creds from env / ~/.sphyr/config.json
|
|
73
|
+
try:
|
|
74
|
+
result = await client.call({"url": "https://api.example.com/data", "mthd": "GET"})
|
|
75
|
+
print(result)
|
|
76
|
+
except SphyrError as err:
|
|
77
|
+
print(err.code, err.retryable, err.docs_url)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
asyncio.run(main())
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### 4. Add the MCP Server (Claude Desktop, Cursor, etc.)
|
|
84
|
+
|
|
85
|
+
**Recommended — one command:**
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
npx @sphyr/cli guard init
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
This opens your browser, signs you in, and writes per-IDE configs automatically — including the `SPHYR_CREDENTIAL` env var. MCP server setup uses the Node-based `sphyr-mcp` binary from `@sphyr/sdk`.
|
|
92
|
+
|
|
93
|
+
See [sphyr.io/docs/mcp](https://sphyr.io/docs/mcp) for per-client setup (Claude Desktop, Claude Code CLI, Cursor, Antigravity, OpenAI Codex).
|
|
94
|
+
|
|
95
|
+
Full reference (retry policy, custom transport, timeout config, error catalog): [sphyr.io/docs](https://sphyr.io/docs)
|
|
96
|
+
|
|
97
|
+
## What traffic is intercepted (and what is not)
|
|
98
|
+
|
|
99
|
+
`instrument()` / `auto_instrument()` work by patching specific HTTP client libraries. Traffic from anything else flows **directly to the network, unguarded** — there is no OS-level interception.
|
|
100
|
+
|
|
101
|
+
| Transport | Intercepted? |
|
|
102
|
+
|---|---|
|
|
103
|
+
| `requests` (Session-based and module-level) | ✅ yes |
|
|
104
|
+
| `httpx` (`Client` and `AsyncClient`) | ✅ yes |
|
|
105
|
+
| `aiohttp` | ❌ no — flows unguarded |
|
|
106
|
+
| `urllib` / `urllib.request` / `http.client` | ❌ no — flows unguarded |
|
|
107
|
+
| `urllib3` (used directly, not via requests) | ❌ no — flows unguarded |
|
|
108
|
+
| `pycurl` / subprocess `curl` | ❌ no — flows unguarded |
|
|
109
|
+
|
|
110
|
+
If your agent uses an unsupported transport, route those calls through `SphyrClient.call()` explicitly, or switch the agent's HTTP layer to `requests`/`httpx`.
|
|
111
|
+
|
|
112
|
+
**Local/private traffic exclusions.** To prevent gateway loops and keep local development working, intercepted clients pass traffic to `localhost`, RFC 1918 ranges (`10/8`, `172.16/12`, `192.168/16`), generic link-local (`169.254/16`), and IPv6 ULA/link-local **directly to the real transport, unguarded**. One deliberate exception: the cloud metadata endpoints (`169.254.169.254`, `fd00:ec2::254`) are always routed through the gateway, which blocks and audits the attempt — these are the primary SSRF targets a prompt-injected agent probes.
|
|
113
|
+
|
|
114
|
+
## Tool Schema Drift Detection
|
|
115
|
+
|
|
116
|
+
When `SphyrClient.instrument()` is active, the SDK automatically detects changes to an MCP server's `tools/list` response between sessions. The first response per origin sets the baseline; subsequent changes raise `SphyrDriftError` (in `BLOCK` mode) or emit a telemetry event and continue (in `LOG` mode, the default).
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from sphyr_sdk import SphyrClient, SphyrDriftError
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
result = await client.call({"url": "https://mcp.example.com/...", "mthd": "GET"})
|
|
123
|
+
except SphyrDriftError as err:
|
|
124
|
+
# Tool schema changed unexpectedly — halt and alert
|
|
125
|
+
print(err.code) # "TOOL_SCHEMA_DRIFT"
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`SphyrDriftError` is also importable from `sdk.agent_guard_utils` for legacy integrations. The enforcement mode (`LOG` or `BLOCK`) is set per API key via `key_flags.tool_drift_mode` in the admin console.
|
|
129
|
+
|
|
130
|
+
## User-Agent header (custom integrations)
|
|
131
|
+
|
|
132
|
+
This SDK sends `User-Agent: sphyr-sdk-python/<version>` on every request to the Sphyr gateway. You do not need to do anything to enable this — it is set automatically.
|
|
133
|
+
|
|
134
|
+
If you are **building a custom MCP client** that talks to the Sphyr gateway directly (i.e. not using this SDK), you must set a `User-Agent` header on your requests. Cloudflare's WAF blocks Python `urllib`'s default `Python-urllib/3.X` User-Agent as a bot — requests fail with HTTP 403 / Error 1010 (`browser_signature_banned`) before they reach the Worker. Any non-empty descriptive value (e.g. `your-app/1.0`) is sufficient.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sphyr-sdk"
|
|
7
|
+
version = "2.0.0-beta.1"
|
|
8
|
+
description = "Python SDK for Sphyr Agent Guard"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"httpx>=0.28.0,<1.0",
|
|
13
|
+
"pydantic>=2.13.0,<3.0",
|
|
14
|
+
# Phase 134 D-12: Ed25519 verification for offline denial-proof verify helper.
|
|
15
|
+
"cryptography>=42.0,<49.0",
|
|
16
|
+
# requests is mandatory (not an optional extra) so the default `pip install sphyr-sdk`
|
|
17
|
+
# guarantees auto_instrument() can patch requests — the dominant agent HTTP library.
|
|
18
|
+
# Previously optional ([instrument] extra); a user who installed without the extra and
|
|
19
|
+
# relied on the SDK to guard requests calls got a silent no-op. Making it mandatory closes
|
|
20
|
+
# that gap at the source without a runtime warning. The [instrument] extra is retained below
|
|
21
|
+
# for backward-compatibility of `pip install sphyr-sdk[instrument]`.
|
|
22
|
+
"requests>=2.32,<3.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
# Retained for backward-compat — requests is now a mandatory dependency (see above).
|
|
27
|
+
instrument = ["requests>=2.32,<3.0"]
|
|
28
|
+
# Dev tooling for coverage (F-009/F-019/F-020) and freezegun timing tests (F-046, plan 92-04 handoff).
|
|
29
|
+
dev = ["pytest-cov>=7.0,<8.0", "freezegun>=1.5,<2.0", "pytest-asyncio>=0.25,<2.0", "requests>=2.32,<3.0"]
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.packages.find]
|
|
32
|
+
where = ["."]
|
|
33
|
+
include = ["sphyr_sdk*"]
|
|
34
|
+
|
|
35
|
+
[tool.pytest.ini_options]
|
|
36
|
+
testpaths = ["tests"]
|
|
37
|
+
python_files = ["test_*.py"]
|
|
38
|
+
# Coverage measurement on every pytest run (F-019 enables branch coverage; fail_under=79 from [tool.coverage.report]).
|
|
39
|
+
addopts = "-q --cov --cov-report=term-missing"
|
|
40
|
+
asyncio_mode = "auto"
|
|
41
|
+
pythonpath = [".", "../.."]
|
|
42
|
+
|
|
43
|
+
# F-019: enable branch coverage for sdk/python (previously UNMEASURED).
|
|
44
|
+
[tool.coverage.run]
|
|
45
|
+
branch = true
|
|
46
|
+
source = ["sphyr_sdk"]
|
|
47
|
+
|
|
48
|
+
# F-009: enforce coverage floor per 92-RESEARCH §3 ratchet plan.
|
|
49
|
+
# Floor stays at 79% — proxy.py removal removes the excluded file but the
|
|
50
|
+
# measured core coverage has not been re-baselined yet. Raise after next CI run.
|
|
51
|
+
[tool.coverage.report]
|
|
52
|
+
fail_under = 79
|
|
53
|
+
show_missing = true
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 Sphyr Agent Guard Contributors
|
|
3
|
+
|
|
4
|
+
"""Python SDK package for Sphyr Agent Guard."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Literal, Optional
|
|
10
|
+
|
|
11
|
+
from sphyr_sdk.client import SphyrClient, AsyncSphyrClient
|
|
12
|
+
from sphyr_sdk._session_models import AuditSessResponse
|
|
13
|
+
from sphyr_sdk.errors import (
|
|
14
|
+
SphyrError,
|
|
15
|
+
SphyrAuthError,
|
|
16
|
+
SphyrRateLimitError,
|
|
17
|
+
SphyrSecurityError,
|
|
18
|
+
SphyrDriftError,
|
|
19
|
+
SphyrNetworkError,
|
|
20
|
+
SphyrBillingError,
|
|
21
|
+
SphyrConfigError,
|
|
22
|
+
SphyrRequestError,
|
|
23
|
+
DegradedModeError,
|
|
24
|
+
make_sphyr_error,
|
|
25
|
+
)
|
|
26
|
+
from sphyr_sdk.verify import verify_denial_proof_locally, VerifyResult
|
|
27
|
+
|
|
28
|
+
# _signing is intentionally excluded — it is an internal primitive, not public API.
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class LibStatus:
|
|
33
|
+
"""Per-library instrumentation status returned by auto_instrument().
|
|
34
|
+
|
|
35
|
+
status:
|
|
36
|
+
'patched' — the HTTP library was found and successfully patched.
|
|
37
|
+
'not_installed' — the library is not installed in this environment.
|
|
38
|
+
reason:
|
|
39
|
+
Optional human-readable note (e.g. an edge-case explanation). None in
|
|
40
|
+
the common cases — 'not_installed' is self-explanatory.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
status: Literal["patched", "not_installed"]
|
|
44
|
+
reason: Optional[str] = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class PatchReport:
|
|
49
|
+
"""Structured report of which HTTP libraries were patched by auto_instrument().
|
|
50
|
+
|
|
51
|
+
Breaking change (v0.27.0): auto_instrument() now returns a PatchReport
|
|
52
|
+
instead of a SphyrClient. Callers that did `client = auto_instrument(...)`
|
|
53
|
+
must update — uninstrumentation is via the module-level sentinels in
|
|
54
|
+
sphyr_sdk._instrument, or by constructing a SphyrClient and calling
|
|
55
|
+
.uninstrument() directly.
|
|
56
|
+
|
|
57
|
+
Attributes:
|
|
58
|
+
requests: Status of the `requests` library patch.
|
|
59
|
+
httpx: Status of the `httpx` library patch (covers both sync and async clients).
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
requests: LibStatus
|
|
63
|
+
httpx: LibStatus
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def auto_instrument(
|
|
67
|
+
*,
|
|
68
|
+
credential: Optional[str] = None,
|
|
69
|
+
mcp_url: Optional[str] = None,
|
|
70
|
+
fail_closed: bool = True,
|
|
71
|
+
) -> PatchReport:
|
|
72
|
+
"""One-line Sphyr setup: patches all HTTP libraries and returns a PatchReport.
|
|
73
|
+
|
|
74
|
+
Mirrors the sentry_sdk.init() pattern. After this call, every outbound
|
|
75
|
+
request via `requests` or `httpx` is automatically signed, scanned, and
|
|
76
|
+
audited through Sphyr.
|
|
77
|
+
|
|
78
|
+
Credential resolves inside SphyrClient with precedence: explicit credential >
|
|
79
|
+
SPHYR_CREDENTIAL env > ~/.sphyr/config.json. So `auto_instrument()` with no
|
|
80
|
+
arguments works after `npx sphyr login`.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
credential: Single sphyr_v1_<keyId>.<signingSecret> credential string.
|
|
84
|
+
Optional — falls back to SPHYR_CREDENTIAL env, then config file.
|
|
85
|
+
mcp_url: Sphyr MCP gateway URL. Optional — falls back to SPHYR_MCP_URL, config file, then public endpoint.
|
|
86
|
+
fail_closed: If True (default), raise SphyrNetworkError when the gateway is
|
|
87
|
+
unreachable so nothing leaves unscreened. Set False to log an
|
|
88
|
+
UNGUARDED warning and pass through (uptime over strict enforcement).
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
PatchReport — per-library instrumentation status. Use to confirm which
|
|
92
|
+
HTTP libraries were patched. The internal SphyrClient is NOT returned;
|
|
93
|
+
use SphyrClient(...).instrument() directly if you need a client reference.
|
|
94
|
+
|
|
95
|
+
Example:
|
|
96
|
+
>>> from sphyr_sdk import auto_instrument
|
|
97
|
+
>>> report = auto_instrument(
|
|
98
|
+
... credential=os.environ["SPHYR_CREDENTIAL"],
|
|
99
|
+
... )
|
|
100
|
+
>>> print(report.requests.status) # 'patched' or 'not_installed'
|
|
101
|
+
>>> # Every requests.get() / httpx.get() call is now signed automatically.
|
|
102
|
+
"""
|
|
103
|
+
# WR-05: warn if instrumentation is already active so silent credential-rotation
|
|
104
|
+
# bugs are detectable. Lazy import mirrors the pattern below.
|
|
105
|
+
import warnings # noqa: PLC0415
|
|
106
|
+
from sphyr_sdk import _instrument as _instr_check # noqa: PLC0415
|
|
107
|
+
if _instr_check._orig_session_init is not None or _instr_check._orig_client_init is not None:
|
|
108
|
+
warnings.warn(
|
|
109
|
+
"[sphyr] auto_instrument() called while instrumentation is already active. "
|
|
110
|
+
"New credentials ignored — call uninstrument() first if you need to re-instrument.",
|
|
111
|
+
stacklevel=2,
|
|
112
|
+
)
|
|
113
|
+
client = SphyrClient(credential=credential, mcp_url=mcp_url)
|
|
114
|
+
client.instrument(fail_closed=fail_closed)
|
|
115
|
+
# Query actual module-level sentinels to determine which libs were patched.
|
|
116
|
+
# Lazy import avoids top-level import cycle (client.py also imports _instrument lazily).
|
|
117
|
+
from sphyr_sdk import _instrument # noqa: PLC0415
|
|
118
|
+
req_status: Literal["patched", "not_installed"] = (
|
|
119
|
+
"patched" if _instrument._orig_session_init is not None else "not_installed"
|
|
120
|
+
)
|
|
121
|
+
httpx_status: Literal["patched", "not_installed"] = (
|
|
122
|
+
"patched" if _instrument._orig_client_init is not None else "not_installed"
|
|
123
|
+
)
|
|
124
|
+
return PatchReport(
|
|
125
|
+
requests=LibStatus(status=req_status),
|
|
126
|
+
httpx=LibStatus(status=httpx_status),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
__all__ = [
|
|
131
|
+
"SphyrClient",
|
|
132
|
+
"AsyncSphyrClient",
|
|
133
|
+
"AuditSessResponse",
|
|
134
|
+
"SphyrError",
|
|
135
|
+
"SphyrAuthError",
|
|
136
|
+
"SphyrRateLimitError",
|
|
137
|
+
"SphyrSecurityError",
|
|
138
|
+
"SphyrDriftError",
|
|
139
|
+
"SphyrNetworkError",
|
|
140
|
+
"SphyrBillingError",
|
|
141
|
+
"SphyrConfigError",
|
|
142
|
+
"SphyrRequestError",
|
|
143
|
+
"DegradedModeError",
|
|
144
|
+
"make_sphyr_error",
|
|
145
|
+
"auto_instrument",
|
|
146
|
+
"verify_denial_proof_locally",
|
|
147
|
+
"VerifyResult",
|
|
148
|
+
"LibStatus",
|
|
149
|
+
"PatchReport",
|
|
150
|
+
]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 Sphyr Agent Guard Contributors
|
|
3
|
+
|
|
4
|
+
"""Argparse dispatcher for `python -m sphyr_sdk`.
|
|
5
|
+
|
|
6
|
+
Subcommands (D-02):
|
|
7
|
+
login — device-flow login via RFC 8628, writes ~/.sphyr/config.json
|
|
8
|
+
verify — connectivity verify: audit_sess handshake + agent_guard_up round-trip
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
python -m sphyr_sdk login
|
|
12
|
+
python -m sphyr_sdk verify [--json]
|
|
13
|
+
|
|
14
|
+
No subcommand or unknown subcommand prints help and exits 1 (Pitfall 8).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import sys
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main() -> None:
|
|
24
|
+
"""Parse argv and dispatch to the appropriate subcommand."""
|
|
25
|
+
parser = argparse.ArgumentParser(
|
|
26
|
+
prog="python -m sphyr_sdk",
|
|
27
|
+
description="Sphyr Agent Guard SDK CLI",
|
|
28
|
+
)
|
|
29
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
30
|
+
|
|
31
|
+
# login subcommand
|
|
32
|
+
subparsers.add_parser(
|
|
33
|
+
"login",
|
|
34
|
+
help="Device-flow login — opens a browser and writes ~/.sphyr/config.json",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# verify subcommand
|
|
38
|
+
verify_parser = subparsers.add_parser(
|
|
39
|
+
"verify",
|
|
40
|
+
help="Verify gateway connectivity (audit_sess handshake + agent_guard_up)",
|
|
41
|
+
)
|
|
42
|
+
verify_parser.add_argument(
|
|
43
|
+
"--json",
|
|
44
|
+
action="store_true",
|
|
45
|
+
default=False,
|
|
46
|
+
help="Emit machine-readable JSON output matching the TS sphyr guard verify schema",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
args = parser.parse_args()
|
|
50
|
+
|
|
51
|
+
# Pitfall 8: no subcommand → print help and exit 1
|
|
52
|
+
if not args.command:
|
|
53
|
+
parser.print_help()
|
|
54
|
+
sys.exit(1)
|
|
55
|
+
|
|
56
|
+
if args.command == "login":
|
|
57
|
+
from sphyr_sdk._login import run_login_command # noqa: PLC0415
|
|
58
|
+
sys.exit(run_login_command())
|
|
59
|
+
|
|
60
|
+
if args.command == "verify":
|
|
61
|
+
from sphyr_sdk._connectivity import run_verify_command # noqa: PLC0415
|
|
62
|
+
sys.exit(run_verify_command(json_mode=getattr(args, "json", False)))
|
|
63
|
+
|
|
64
|
+
# Unknown command (defensive — argparse should handle most cases above)
|
|
65
|
+
parser.print_help()
|
|
66
|
+
sys.exit(1)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
if __name__ == "__main__":
|
|
70
|
+
main()
|