sphyr-sdk 2.0.0b1__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.
sphyr_sdk/__init__.py ADDED
@@ -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
+ ]
sphyr_sdk/__main__.py ADDED
@@ -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()
@@ -0,0 +1,207 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 Sphyr Agent Guard Contributors
3
+
4
+ """Gateway connectivity verify for `python -m sphyr_sdk verify`.
5
+
6
+ This module implements the `verify` subcommand — a port of sdk/ts/guard-verify.ts.
7
+ It performs a real AUTH-08 audit_sess handshake followed by an agent_guard_up
8
+ round-trip and reports per-library instrumentation status.
9
+
10
+ DISTINCT from verify.py (sphyr_sdk.verify), which is the offline Ed25519
11
+ denial-receipt verifier and has no network dependency.
12
+
13
+ Security:
14
+ - T-137-16: Calls agent_guard_up by literal tool name via JSON-RPC — never the
15
+ legacy tool name, and never via AgentGuardClient.agent_guard_up()
16
+ (which calls an unregistered tool name internally)
17
+ - T-137-17: Credential never printed — only pass/fail status is emitted
18
+ - T-137-18: --json output schema is byte-for-byte compatible with TS verify schema
19
+ (only instrumentation keys differ: requests/httpx vs fetch/axios)
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import hashlib
25
+ import hmac
26
+ import json
27
+ import sys
28
+ import time
29
+ import uuid
30
+ from typing import Any, Dict
31
+
32
+ import httpx
33
+
34
+ from sphyr_sdk import auto_instrument
35
+ from sphyr_sdk.client import _resolve_credentials, _parse_credential
36
+ from sphyr_sdk._instrument import _unpatch_requests, _unpatch_httpx
37
+
38
+ _DEFAULT_MCP_URL = "https://api.sphyr.io/mcp"
39
+
40
+
41
+ def _call_tool_once(mcp_url: str, name: str, args: Dict[str, Any]) -> Dict[str, Any]:
42
+ """POST a JSON-RPC tools/call envelope and return the parsed result dict.
43
+
44
+ Raises on HTTP error, envelope-level error, or malformed response.
45
+ """
46
+ envelope = {
47
+ "jsonrpc": "2.0",
48
+ "method": "tools/call",
49
+ "params": {"name": name, "arguments": args},
50
+ "id": "1",
51
+ }
52
+ response = httpx.post(
53
+ mcp_url,
54
+ json=envelope,
55
+ headers={
56
+ "Content-Type": "application/json",
57
+ "Accept": "application/json, text/event-stream",
58
+ },
59
+ timeout=10.0,
60
+ )
61
+ response.raise_for_status()
62
+ body: Dict[str, Any] = response.json()
63
+ if "error" in body:
64
+ raise RuntimeError(f"MCP error: {body['error']}")
65
+ result_text: str = body["result"]["content"][0]["text"]
66
+ return json.loads(result_text)
67
+
68
+
69
+ def _build_audit_sess_args(key_id: str, signing_secret: str) -> Dict[str, str]:
70
+ """Build the AUTH-08 audit_sess argument dict.
71
+
72
+ Payload: key_id:agent_id:ts:client_nonce (colon-joined, HMAC-SHA256 hex).
73
+ agent_id is a fresh UUID per verification run (non-persistent, per D-03).
74
+ """
75
+ agent_id = str(uuid.uuid4())
76
+ ts = str(int(time.time()))
77
+ client_nonce = str(uuid.uuid4())
78
+ payload = f"{key_id}:{agent_id}:{ts}:{client_nonce}"
79
+ sig = hmac.new(
80
+ signing_secret.encode("utf-8"),
81
+ payload.encode("utf-8"),
82
+ hashlib.sha256,
83
+ ).hexdigest()
84
+ return {
85
+ "key_id": key_id,
86
+ "agent_id": agent_id,
87
+ "ts": ts,
88
+ "client_nonce": client_nonce,
89
+ "sig": sig,
90
+ }
91
+
92
+
93
+ def run_verify_command(json_mode: bool = False) -> int: # noqa: PLR0912 -- verify sequence: credential → probe → teardown → handshake → agent_guard_up → render; ordering invariant breaks if split (teardown must precede network calls)
94
+ """Run the connectivity verify command.
95
+
96
+ Steps mirror guard-verify.ts:
97
+ 1. Resolve credential (env > config file)
98
+ 2. Probe instrumentation, then tear it down BEFORE any MCP network call
99
+ 3. AUTH-08 audit_sess handshake
100
+ 4. agent_guard_up round-trip (direct JSON-RPC by name — T-137-16)
101
+ 5. Render output (--json or human-readable)
102
+ 6. Return 0 on full pass, 1 on any failure
103
+
104
+ Returns:
105
+ 0 if gateway reachable + handshake ok + agent_guard_up ok
106
+ 1 otherwise
107
+ """
108
+ # ── Step 1: Resolve credential ──────────────────────────────────────────────
109
+ credential, mcp_url = _resolve_credentials(None, None)
110
+ if not credential:
111
+ print(
112
+ "No credential found. Run 'python -m sphyr_sdk login' or set SPHYR_CREDENTIAL.",
113
+ file=sys.stderr,
114
+ )
115
+ return 1
116
+
117
+ key_id, signing_secret = _parse_credential(credential)
118
+
119
+ # ── Step 2: Probe instrumentation, then uninstrument BEFORE any network call ─
120
+ # Mirror guard-verify.ts ordering (T-136-10 / T-137-18): probe so we can report
121
+ # status, then tear down immediately so MCP traffic doesn't route through Sphyr.
122
+ try:
123
+ report = auto_instrument(credential=credential, mcp_url=mcp_url)
124
+ finally:
125
+ _unpatch_requests()
126
+ _unpatch_httpx()
127
+
128
+ # ── Step 3 + 4: handshake + agent_guard_up ─────────────────────────────────
129
+ gateway_reachable = False
130
+ handshake_ok = False
131
+ agent_guard_up_ok = False
132
+ sess_balance: float | None = None
133
+
134
+ try:
135
+ audit_sess_args = _build_audit_sess_args(key_id, signing_secret)
136
+ audit_sess_data = _call_tool_once(mcp_url, "audit_sess", audit_sess_args)
137
+
138
+ gateway_reachable = True # audit_sess responded (even with an error body)
139
+
140
+ sess_token = audit_sess_data.get("sess_token")
141
+ sess_balance = audit_sess_data.get("balance")
142
+ if sess_token:
143
+ handshake_ok = True
144
+
145
+ try:
146
+ # T-137-16: MUST call "agent_guard_up" by name via JSON-RPC.
147
+ # Direct JSON-RPC only — no legacy client wrappers.
148
+ guard_up_data = _call_tool_once(
149
+ mcp_url, "agent_guard_up", {"sess_token": sess_token}
150
+ )
151
+ status = guard_up_data.get("status")
152
+ if status in ("ACTIVE", "DEGRADED"):
153
+ agent_guard_up_ok = True
154
+ except Exception:
155
+ agent_guard_up_ok = False
156
+
157
+ except Exception:
158
+ # fetch error, HTTP error, or JSON parse failure — gateway unreachable / handshake failed
159
+ pass
160
+
161
+ # ── Step 5: Compute overall pass/fail ───────────────────────────────────────
162
+ ok = gateway_reachable and handshake_ok and agent_guard_up_ok
163
+
164
+ # ── Step 6: Render output ───────────────────────────────────────────────────
165
+ if json_mode:
166
+ # T-137-18: schema must match TS verify --json exactly (instrumentation keys differ)
167
+ output = {
168
+ "ok": ok,
169
+ "gateway": {"reachable": gateway_reachable, "url": mcp_url},
170
+ "handshake": {"ok": handshake_ok, "balance": sess_balance},
171
+ "agent_guard_up": {"ok": agent_guard_up_ok},
172
+ "instrumentation": {
173
+ "requests": {"status": report.requests.status},
174
+ "httpx": {"status": report.httpx.status},
175
+ },
176
+ }
177
+ print(json.dumps(output))
178
+ else:
179
+ # Human-readable summary (T-137-17: credential never printed)
180
+ print("✓ Credential loaded")
181
+ if gateway_reachable:
182
+ try:
183
+ from urllib.parse import urlparse as _urlparse # noqa: PLC0415
184
+ host = _urlparse(mcp_url).hostname or mcp_url
185
+ except Exception:
186
+ host = mcp_url
187
+ print(f"✓ Gateway reachable ({host})")
188
+ else:
189
+ print("✗ Gateway unreachable")
190
+
191
+ print("✓ Handshake succeeded" if handshake_ok else "✗ Handshake failed")
192
+ print(
193
+ "✓ agent_guard_up responded"
194
+ if agent_guard_up_ok
195
+ else "✗ agent_guard_up did not respond"
196
+ )
197
+
198
+ for lib_name, lib_status in [("requests", report.requests), ("httpx", report.httpx)]:
199
+ if lib_status.status == "patched":
200
+ print(f"✓ {lib_name}: patched")
201
+ else:
202
+ print(f"- {lib_name}: not installed")
203
+
204
+ print()
205
+ print("sphyr-mcp: OK" if ok else "sphyr-mcp: FAILED")
206
+
207
+ return 0 if ok else 1