revoc-sdk 1.0.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,159 @@
1
+ # ─────────────────────────────────────────────────────────────────────────────
2
+ # REVOC .gitignore
3
+ # Rust + Python + Node.js + IDE + OS
4
+ # IMPORTANT: crates/revoc-dashboard/ui/dist/ is NOT ignored — it must be
5
+ # committed so the single-container image works without a Node build.
6
+ # ─────────────────────────────────────────────────────────────────────────────
7
+
8
+ # ── Rust ──────────────────────────────────────────────────────────────────────
9
+ /target/
10
+ debug/
11
+ **/*.rs.bk
12
+ *.pdb
13
+ **/mutants.out*/
14
+
15
+ # ── Fuzz ──────────────────────────────────────────────────────────────────────
16
+ # Keep: fuzz/Cargo.toml, fuzz/Cargo.lock, fuzz/fuzz_targets/, fuzz/artifacts/
17
+ # Ignore: compiled binaries and generated corpus (too large, too noisy)
18
+ fuzz/target/
19
+ fuzz/corpus/
20
+
21
+ # ── Cargo Chef artifacts ──────────────────────────────────────────────────────
22
+ recipe.json
23
+ req.json
24
+
25
+ # ── Environment & Secrets ─────────────────────────────────────────────────────
26
+ .env
27
+ .env.*
28
+ !.env.example
29
+ *.pem
30
+ *.key
31
+ *.p12
32
+ *.pfx
33
+ api_keys.txt
34
+ api-keys.txt
35
+
36
+ # ── Python ────────────────────────────────────────────────────────────────────
37
+ __pycache__/
38
+ *.py[cod]
39
+ *$py.class
40
+ *.pyo
41
+ *.pyd
42
+ .Python
43
+ .venv/
44
+ venv/
45
+ env/
46
+ ENV/
47
+ pip-wheel-metadata/
48
+ *.egg-info/
49
+ .eggs/
50
+ .pytest_cache/
51
+ htmlcov/
52
+ .coverage
53
+ .coverage.*
54
+ coverage.xml
55
+ *.cover
56
+ .hypothesis/
57
+ .mypy_cache/
58
+ .dmypy.json
59
+ dmypy.json
60
+ .ruff_cache/
61
+ .pytype/
62
+
63
+ # ── Node.js ───────────────────────────────────────────────────────────────────
64
+ node_modules/
65
+ npm-debug.log*
66
+ yarn-debug.log*
67
+ yarn-error.log*
68
+ .pnpm-debug.log*
69
+ .npm
70
+ .yarn/
71
+ .pnp.*
72
+ # Build output for SDK packages (NOT for revoc-dashboard/ui/dist — see below)
73
+ sdk/node/dist/
74
+ sdk/node/lib/
75
+
76
+ # ── Dashboard React SPA ───────────────────────────────────────────────────────
77
+ # CRITICAL: DO NOT add crates/revoc-dashboard/ui/dist/ here.
78
+ # The pre-built SPA must be committed to git so the Rust binary can embed it
79
+ # via include_dir! without requiring a Node build step in the container.
80
+ crates/revoc-dashboard/ui/node_modules/
81
+ crates/revoc-dashboard/ui/.parcel-cache/
82
+ crates/revoc-dashboard/ui/.vite/
83
+
84
+ # ── Database & Storage ────────────────────────────────────────────────────────
85
+ *.db
86
+ *.db-shm
87
+ *.db-wal
88
+ *.sqlite
89
+ *.sqlite3
90
+ revoc-audit.db*
91
+
92
+ # ── Logs ──────────────────────────────────────────────────────────────────────
93
+ *.log
94
+ logs/
95
+ *.log.*
96
+
97
+ # ── ML Models ────────────────────────────────────────────────────────────────
98
+ # ONNX models and tokenizer files are large binaries — downloaded via models/download.sh
99
+ models/*.onnx
100
+ models/*.json
101
+ !models/README.md
102
+ !models/download.sh
103
+
104
+ # ── Test Artifacts ───────────────────────────────────────────────────────────
105
+ # Locust CSV output and load test result files — generated on every run
106
+ tests/load/results/
107
+
108
+ # ── Generated / Build Artifacts ───────────────────────────────────────────────
109
+ site/
110
+ lcov.info
111
+ lcov-*.info
112
+ coverage/
113
+ *.profraw
114
+ *.profdata
115
+
116
+ # ── Claude IDE local settings ─────────────────────────────────────────────────
117
+ .claude/settings.local.json
118
+
119
+ # ── IDE ───────────────────────────────────────────────────────────────────────
120
+ .idea/
121
+ .vscode/
122
+ *.iml
123
+ *.ipr
124
+ *.iws
125
+ .fleet/
126
+ .zed/
127
+ *.swp
128
+ *.swo
129
+ *~
130
+ .project
131
+ .classpath
132
+ .settings/
133
+
134
+ # ── OS ────────────────────────────────────────────────────────────────────────
135
+ .DS_Store
136
+ .DS_Store?
137
+ ._*
138
+ .Spotlight-V100
139
+ .Trashes
140
+ ehthumbs.db
141
+ Thumbs.db
142
+ desktop.ini
143
+
144
+ # ── Helm ──────────────────────────────────────────────────────────────────────
145
+ helm/revoc/charts/
146
+ helm/revoc/*.tgz
147
+
148
+ # ── Misc ──────────────────────────────────────────────────────────────────────
149
+ .cargo-ok
150
+ flamegraph.svg
151
+ perf.data
152
+ perf.data.old
153
+ heaptrack.*
154
+ massif.out.*
155
+ dist/
156
+ build/
157
+ # CRITICAL: revoc-dashboard pre-built SPA must be committed (SPEC.md §6)
158
+ !crates/revoc-dashboard/ui/dist/
159
+ !crates/revoc-dashboard/ui/dist/**
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.5
2
+ Name: revoc-sdk
3
+ Version: 1.0.0
4
+ Summary: Zero-code-change governance SDK for REVOC — drop-in OpenAI client wrapper.
5
+ Project-URL: Homepage, https://revoc.io
6
+ Project-URL: Documentation, https://revoc.io
7
+ Project-URL: Repository, https://github.com/revoc-ai/revoc
8
+ Project-URL: Bug Tracker, https://github.com/revoc-ai/revoc/issues
9
+ Project-URL: Changelog, https://github.com/revoc-ai/revoc/blob/main/CHANGELOG.md
10
+ Author: REVOC
11
+ License: Apache-2.0
12
+ Keywords: ai,governance,llm,openai,pii,proxy,redaction,security
13
+ Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: Apache Software License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Security
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.12
22
+ Requires-Dist: httpx>=0.27
23
+ Requires-Dist: openai>=1.50
24
+ Requires-Dist: pydantic>=2.0
25
+ Provides-Extra: dev
26
+ Requires-Dist: mypy>=1.13; extra == 'dev'
27
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
28
+ Requires-Dist: pytest>=8.0; extra == 'dev'
29
+ Requires-Dist: ruff>=0.7; extra == 'dev'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # revoc-sdk
33
+
34
+ Zero-code-change governance SDK for [REVOC](https://revoc.io) — drop-in
35
+ subclasses of the official `openai` Python client that route traffic through
36
+ the REVOC proxy and translate governance decisions into typed exceptions.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ pip install revoc-sdk
42
+ ```
43
+
44
+ ## Usage
45
+
46
+ ```python
47
+ from revoc_sdk import RevocOpenAI, RevocGovernanceError
48
+
49
+ client = RevocOpenAI(api_key="sk-...", revoc_base_url="http://revoc:8080")
50
+
51
+ try:
52
+ resp = client.chat.completions.create(
53
+ model="gpt-4o-mini",
54
+ messages=[{"role": "user", "content": "Hello"}],
55
+ )
56
+ except RevocGovernanceError as exc:
57
+ print(f"Blocked by {exc.blocked_by}: {exc.reason}")
58
+ print(f"See {exc.dashboard_url}")
59
+ ```
60
+
61
+ ## Zero-code-change activation
62
+
63
+ ```bash
64
+ export REVOC_BASE_URL=http://revoc:8080
65
+ ```
66
+
67
+ ```python
68
+ import revoc_sdk
69
+ revoc_sdk.patch() # patches openai.OpenAI / openai.AsyncOpenAI
70
+
71
+ from openai import OpenAI
72
+ client = OpenAI(api_key="sk-...") # transparently routed through REVOC
73
+ ```
74
+
75
+ ## Governance API
76
+
77
+ ```python
78
+ import asyncio
79
+ from datetime import datetime, timedelta, timezone
80
+
81
+ async def main():
82
+ stats = await client.governance.get_audit_stats(
83
+ start_time=datetime.now(timezone.utc) - timedelta(days=1),
84
+ end_time=datetime.now(timezone.utc),
85
+ )
86
+ print(stats)
87
+
88
+ asyncio.run(main())
89
+ ```
90
+
91
+ ## Exceptions
92
+
93
+ | Exception | Raised when |
94
+ |---|---|
95
+ | `RevocGovernanceError` | A policy rule blocked the request |
96
+ | `RevocKillSwitchError` | The emergency kill-switch is active |
97
+ | `RevocDepthExceededError` | Governance depth reached the maximum (10) |
98
+ | `RevocRateLimitError` | The caller exceeded their rate limit |
99
+ | `RevocMcpError` | An MCP `tools/call` was blocked before reaching the server |
100
+
101
+ ## License
102
+
103
+ Apache 2.0
@@ -0,0 +1,72 @@
1
+ # revoc-sdk
2
+
3
+ Zero-code-change governance SDK for [REVOC](https://revoc.io) — drop-in
4
+ subclasses of the official `openai` Python client that route traffic through
5
+ the REVOC proxy and translate governance decisions into typed exceptions.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install revoc-sdk
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```python
16
+ from revoc_sdk import RevocOpenAI, RevocGovernanceError
17
+
18
+ client = RevocOpenAI(api_key="sk-...", revoc_base_url="http://revoc:8080")
19
+
20
+ try:
21
+ resp = client.chat.completions.create(
22
+ model="gpt-4o-mini",
23
+ messages=[{"role": "user", "content": "Hello"}],
24
+ )
25
+ except RevocGovernanceError as exc:
26
+ print(f"Blocked by {exc.blocked_by}: {exc.reason}")
27
+ print(f"See {exc.dashboard_url}")
28
+ ```
29
+
30
+ ## Zero-code-change activation
31
+
32
+ ```bash
33
+ export REVOC_BASE_URL=http://revoc:8080
34
+ ```
35
+
36
+ ```python
37
+ import revoc_sdk
38
+ revoc_sdk.patch() # patches openai.OpenAI / openai.AsyncOpenAI
39
+
40
+ from openai import OpenAI
41
+ client = OpenAI(api_key="sk-...") # transparently routed through REVOC
42
+ ```
43
+
44
+ ## Governance API
45
+
46
+ ```python
47
+ import asyncio
48
+ from datetime import datetime, timedelta, timezone
49
+
50
+ async def main():
51
+ stats = await client.governance.get_audit_stats(
52
+ start_time=datetime.now(timezone.utc) - timedelta(days=1),
53
+ end_time=datetime.now(timezone.utc),
54
+ )
55
+ print(stats)
56
+
57
+ asyncio.run(main())
58
+ ```
59
+
60
+ ## Exceptions
61
+
62
+ | Exception | Raised when |
63
+ |---|---|
64
+ | `RevocGovernanceError` | A policy rule blocked the request |
65
+ | `RevocKillSwitchError` | The emergency kill-switch is active |
66
+ | `RevocDepthExceededError` | Governance depth reached the maximum (10) |
67
+ | `RevocRateLimitError` | The caller exceeded their rate limit |
68
+ | `RevocMcpError` | An MCP `tools/call` was blocked before reaching the server |
69
+
70
+ ## License
71
+
72
+ Apache 2.0
@@ -0,0 +1,61 @@
1
+ [project]
2
+ name = "revoc-sdk"
3
+ version = "1.0.0"
4
+ description = "Zero-code-change governance SDK for REVOC — drop-in OpenAI client wrapper."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = { text = "Apache-2.0" }
8
+ authors = [{ name = "REVOC" }]
9
+ keywords = ["ai", "governance", "llm", "proxy", "security", "pii", "redaction", "openai"]
10
+ classifiers = [
11
+ "Development Status :: 5 - Production/Stable",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: Apache Software License",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.12",
16
+ "Topic :: Security",
17
+ "Topic :: Software Development :: Libraries :: Python Modules",
18
+ "Typing :: Typed",
19
+ ]
20
+ dependencies = [
21
+ "openai>=1.50",
22
+ "httpx>=0.27",
23
+ "pydantic>=2.0",
24
+ ]
25
+
26
+ [project.optional-dependencies]
27
+ dev = [
28
+ "pytest>=8.0",
29
+ "pytest-asyncio>=0.24",
30
+ "ruff>=0.7",
31
+ "mypy>=1.13",
32
+ ]
33
+
34
+ [project.urls]
35
+ Homepage = "https://revoc.io"
36
+ Documentation = "https://revoc.io"
37
+ Repository = "https://github.com/revoc-ai/revoc"
38
+ "Bug Tracker" = "https://github.com/revoc-ai/revoc/issues"
39
+ Changelog = "https://github.com/revoc-ai/revoc/blob/main/CHANGELOG.md"
40
+
41
+ [build-system]
42
+ requires = ["hatchling"]
43
+ build-backend = "hatchling.build"
44
+
45
+ [tool.hatch.build.targets.wheel]
46
+ packages = ["revoc_sdk"]
47
+
48
+ [tool.pytest.ini_options]
49
+ asyncio_mode = "auto"
50
+ testpaths = ["tests"]
51
+
52
+ [tool.ruff]
53
+ line-length = 100
54
+ target-version = "py312"
55
+
56
+ [tool.ruff.lint]
57
+ select = ["E", "F", "I", "UP", "B", "DTZ"]
58
+
59
+ [tool.mypy]
60
+ python_version = "3.12"
61
+ strict = true
@@ -0,0 +1,38 @@
1
+ """revoc_sdk — zero-code-change governance SDK for REVOC.
2
+
3
+ from revoc_sdk import RevocOpenAI
4
+ client = RevocOpenAI(api_key="sk-...")
5
+
6
+ See https://revoc.io/docs for full documentation.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from revoc_sdk.client import RevocAsyncOpenAI, RevocOpenAI
12
+ from revoc_sdk.exceptions import (
13
+ RevocDepthExceededError,
14
+ RevocGovernanceError,
15
+ RevocKillSwitchError,
16
+ RevocRateLimitError,
17
+ )
18
+ from revoc_sdk.governance import GovernanceClient
19
+ from revoc_sdk.mcp import RevocMcpClient, RevocMcpError, governed_tool_call
20
+ from revoc_sdk.patch import patch, unpatch
21
+
22
+ __version__ = "1.0.0"
23
+
24
+ __all__ = [
25
+ "GovernanceClient",
26
+ "RevocAsyncOpenAI",
27
+ "RevocDepthExceededError",
28
+ "RevocGovernanceError",
29
+ "RevocKillSwitchError",
30
+ "RevocMcpClient",
31
+ "RevocMcpError",
32
+ "RevocOpenAI",
33
+ "RevocRateLimitError",
34
+ "__version__",
35
+ "governed_tool_call",
36
+ "patch",
37
+ "unpatch",
38
+ ]
@@ -0,0 +1,194 @@
1
+ """revoc_sdk.client — drop-in OpenAI client subclasses routed through REVOC.
2
+
3
+ Usage (zero-code-change beyond the constructor):
4
+
5
+ from revoc_sdk import RevocOpenAI
6
+ client = RevocOpenAI(api_key="sk-...") # reads REVOC_BASE_URL
7
+ client = RevocOpenAI(api_key="sk-...", revoc_base_url="http://revoc:8080")
8
+
9
+ Both subclasses transparently translate REVOC governance error responses
10
+ (`RevocErrorResponse`, see SPEC.md Section 7.5) into typed Python
11
+ exceptions so callers can branch on governance outcomes instead of parsing
12
+ raw HTTP error bodies.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ from typing import Any
19
+
20
+ import httpx
21
+ from openai import AsyncOpenAI, OpenAI
22
+
23
+ from revoc_sdk.exceptions import (
24
+ RevocDepthExceededError,
25
+ RevocGovernanceError,
26
+ RevocKillSwitchError,
27
+ RevocRateLimitError,
28
+ )
29
+ from revoc_sdk.governance import GovernanceClient
30
+
31
+
32
+ def _response_hook(response: httpx.Response) -> None:
33
+ """httpx event hook: parse REVOC error bodies and raise typed exceptions.
34
+
35
+ Only fires on 4xx/5xx responses, and only acts when the body matches the
36
+ `RevocErrorResponse` shape (`{"error": {"code": "REVOC_...", ...}}`).
37
+ Upstream provider errors are passed through untouched (Rule 8: forward
38
+ verbatim, never wrap).
39
+ """
40
+ if response.status_code < 400:
41
+ return
42
+ try:
43
+ response.read()
44
+ payload = response.json()
45
+ except Exception: # noqa: BLE001 — malformed/non-JSON bodies pass through
46
+ return
47
+ if not isinstance(payload, dict):
48
+ return
49
+ error = payload.get("error")
50
+ if not isinstance(error, dict):
51
+ return
52
+ code = error.get("code", "")
53
+ if not isinstance(code, str) or not code.startswith("REVOC_"):
54
+ return
55
+ if code == "REVOC_RATE_LIMIT_EXCEEDED":
56
+ retry_after = 0
57
+ try:
58
+ retry_after = int(response.headers.get("retry-after", "0"))
59
+ except ValueError:
60
+ retry_after = 0
61
+ raise RevocRateLimitError(retry_after_secs=retry_after)
62
+ if code == "REVOC_KILL_SWITCH_ACTIVE":
63
+ raise RevocKillSwitchError(
64
+ scope="global", dashboard_url=error.get("dashboard_url", "")
65
+ )
66
+ if code == "REVOC_GOVERNANCE_DEPTH_EXCEEDED":
67
+ depth = 10
68
+ message = error.get("message", "")
69
+ try:
70
+ fraction = message.split("(", 1)[1].split(")", 1)[0]
71
+ depth = int(fraction.split("/", 1)[0])
72
+ except (IndexError, ValueError):
73
+ pass
74
+ raise RevocDepthExceededError(depth=depth, max_depth=10)
75
+ if code == "REVOC_GOVERNANCE_BLOCK":
76
+ raise RevocGovernanceError(
77
+ reason=error.get("message", ""),
78
+ decision="block",
79
+ blocked_by=error.get("blocked_by"),
80
+ rules=error.get("rules") or [],
81
+ trace_id=error.get("trace_id", ""),
82
+ dashboard_url=error.get("dashboard_url", ""),
83
+ documentation_url=error.get("documentation_url"),
84
+ )
85
+
86
+
87
+ def _resolve_base_url(revoc_base_url: str | None, kwargs: dict[str, Any]) -> str | None:
88
+ """Resolution order: explicit kwarg > REVOC_BASE_URL env > caller-supplied base_url."""
89
+ if revoc_base_url:
90
+ return revoc_base_url
91
+ env_url = os.environ.get("REVOC_BASE_URL")
92
+ if env_url:
93
+ return env_url
94
+ existing = kwargs.pop("base_url", None)
95
+ return existing if isinstance(existing, str) or existing is None else None
96
+
97
+
98
+ class RevocOpenAI(OpenAI):
99
+ """Synchronous OpenAI client transparently governed by REVOC.
100
+
101
+ Drop-in replacement for ``openai.OpenAI``. All requests are routed
102
+ through the REVOC proxy (`base_url`), and governance error responses are
103
+ raised as typed exceptions (`RevocGovernanceError`, `RevocKillSwitchError`,
104
+ `RevocDepthExceededError`, `RevocRateLimitError`).
105
+ """
106
+
107
+ def __init__(self, *, revoc_base_url: str | None = None, **kwargs: Any) -> None:
108
+ base_url = _resolve_base_url(revoc_base_url, kwargs)
109
+ http_client = kwargs.pop("http_client", None)
110
+ if http_client is None:
111
+ http_client = httpx.Client(event_hooks={"response": [_response_hook]})
112
+ super().__init__(base_url=base_url, http_client=http_client, **kwargs)
113
+ self._revoc_governance: GovernanceClient | None = None
114
+
115
+ @property
116
+ def governance(self) -> GovernanceClient:
117
+ """Lazily-constructed client for the REVOC dashboard / admin-api."""
118
+ if self._revoc_governance is None:
119
+ base = str(self.base_url) if self.base_url else ""
120
+ self._revoc_governance = GovernanceClient(base_url=base)
121
+ return self._revoc_governance
122
+
123
+
124
+ class RevocAsyncOpenAI(AsyncOpenAI):
125
+ """Asynchronous OpenAI client transparently governed by REVOC.
126
+
127
+ Drop-in replacement for ``openai.AsyncOpenAI`` with the same governance
128
+ exception translation as `RevocOpenAI`.
129
+ """
130
+
131
+ def __init__(self, *, revoc_base_url: str | None = None, **kwargs: Any) -> None:
132
+ base_url = _resolve_base_url(revoc_base_url, kwargs)
133
+ http_client = kwargs.pop("http_client", None)
134
+ if http_client is None:
135
+ http_client = httpx.AsyncClient(event_hooks={"response": [_async_response_hook]})
136
+ super().__init__(base_url=base_url, http_client=http_client, **kwargs)
137
+ self._revoc_governance: GovernanceClient | None = None
138
+
139
+ @property
140
+ def governance(self) -> GovernanceClient:
141
+ """Lazily-constructed client for the REVOC dashboard / admin-api."""
142
+ if self._revoc_governance is None:
143
+ base = str(self.base_url) if self.base_url else ""
144
+ self._revoc_governance = GovernanceClient(base_url=base)
145
+ return self._revoc_governance
146
+
147
+
148
+ async def _async_response_hook(response: httpx.Response) -> None:
149
+ """Async variant of `_response_hook` for `httpx.AsyncClient` event hooks."""
150
+ if response.status_code < 400:
151
+ return
152
+ try:
153
+ await response.aread()
154
+ payload = response.json()
155
+ except Exception: # noqa: BLE001
156
+ return
157
+ if not isinstance(payload, dict):
158
+ return
159
+ error = payload.get("error")
160
+ if not isinstance(error, dict):
161
+ return
162
+ code = error.get("code", "")
163
+ if not isinstance(code, str) or not code.startswith("REVOC_"):
164
+ return
165
+ if code == "REVOC_RATE_LIMIT_EXCEEDED":
166
+ retry_after = 0
167
+ try:
168
+ retry_after = int(response.headers.get("retry-after", "0"))
169
+ except ValueError:
170
+ retry_after = 0
171
+ raise RevocRateLimitError(retry_after_secs=retry_after)
172
+ if code == "REVOC_KILL_SWITCH_ACTIVE":
173
+ raise RevocKillSwitchError(
174
+ scope="global", dashboard_url=error.get("dashboard_url", "")
175
+ )
176
+ if code == "REVOC_GOVERNANCE_DEPTH_EXCEEDED":
177
+ depth = 10
178
+ message = error.get("message", "")
179
+ try:
180
+ fraction = message.split("(", 1)[1].split(")", 1)[0]
181
+ depth = int(fraction.split("/", 1)[0])
182
+ except (IndexError, ValueError):
183
+ pass
184
+ raise RevocDepthExceededError(depth=depth, max_depth=10)
185
+ if code == "REVOC_GOVERNANCE_BLOCK":
186
+ raise RevocGovernanceError(
187
+ reason=error.get("message", ""),
188
+ decision="block",
189
+ blocked_by=error.get("blocked_by"),
190
+ rules=error.get("rules") or [],
191
+ trace_id=error.get("trace_id", ""),
192
+ dashboard_url=error.get("dashboard_url", ""),
193
+ documentation_url=error.get("documentation_url"),
194
+ )
@@ -0,0 +1,91 @@
1
+ """revoc_sdk.exceptions — typed exceptions raised when REVOC governs a request.
2
+
3
+ These map 1:1 onto the `error.code` values produced by `RevocErrorResponse`
4
+ (see SPEC.md Section 7.5 / types/openai.rs). The REVOC client inspects the
5
+ JSON error body returned by the proxy and raises the matching exception type
6
+ so application code can branch on governance outcomes without parsing HTTP
7
+ responses by hand.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+
13
+ class RevocGovernanceError(Exception):
14
+ """Raised when REVOC blocks a request due to a policy violation.
15
+
16
+ Corresponds to `error.code == "REVOC_GOVERNANCE_BLOCK"`.
17
+ """
18
+
19
+ def __init__(
20
+ self,
21
+ decision: str,
22
+ blocked_by: str | None,
23
+ rules: list[str],
24
+ trace_id: str,
25
+ dashboard_url: str,
26
+ documentation_url: str | None = None,
27
+ *,
28
+ reason: str | None = None,
29
+ ) -> None:
30
+ self.decision = decision
31
+ self.blocked_by = blocked_by
32
+ self.rules = rules
33
+ self.trace_id = trace_id
34
+ self.dashboard_url = dashboard_url
35
+ self.documentation_url = documentation_url
36
+ rule_list = ", ".join(rules) or blocked_by or "unknown"
37
+ self.reason = reason or f"Request blocked by rule(s): {rule_list}"
38
+ super().__init__(str(self))
39
+
40
+ def __str__(self) -> str:
41
+ return (
42
+ f"REVOC blocked [{self.blocked_by}]: {self.reason}\n"
43
+ f"Trace: {self.trace_id}\nDashboard: {self.dashboard_url}"
44
+ )
45
+
46
+
47
+ class RevocKillSwitchError(Exception):
48
+ """Raised when the emergency kill-switch is active.
49
+
50
+ Corresponds to `error.code == "REVOC_KILL_SWITCH_ACTIVE"`.
51
+ """
52
+
53
+ def __init__(self, *, scope: str, dashboard_url: str) -> None:
54
+ self.scope = scope
55
+ self.dashboard_url = dashboard_url
56
+ super().__init__(str(self))
57
+
58
+ def __str__(self) -> str:
59
+ return "REVOC emergency stop active. Contact your AI platform team."
60
+
61
+
62
+ class RevocDepthExceededError(Exception):
63
+ """Raised when governance depth reaches the configured maximum.
64
+
65
+ Corresponds to `error.code == "REVOC_GOVERNANCE_DEPTH_EXCEEDED"`.
66
+ """
67
+
68
+ def __init__(self, *, depth: int, max_depth: int = 10) -> None:
69
+ self.depth = depth
70
+ self.max_depth = max_depth
71
+ super().__init__(str(self))
72
+
73
+ def __str__(self) -> str:
74
+ return (
75
+ f"Governance depth {self.depth}/{self.max_depth} reached. "
76
+ "Possible infinite agent loop."
77
+ )
78
+
79
+
80
+ class RevocRateLimitError(Exception):
81
+ """Raised when the caller has exceeded their configured rate limit.
82
+
83
+ Corresponds to `error.code == "REVOC_RATE_LIMIT_EXCEEDED"`.
84
+ """
85
+
86
+ def __init__(self, *, retry_after_secs: int) -> None:
87
+ self.retry_after_secs = retry_after_secs
88
+ super().__init__(str(self))
89
+
90
+ def __str__(self) -> str:
91
+ return f"REVOC rate limit exceeded. Retry after {self.retry_after_secs} seconds."
@@ -0,0 +1,96 @@
1
+ """revoc_sdk.governance — async client for the REVOC dashboard / admin-api.
2
+
3
+ Exposed via `client.governance` on `RevocOpenAI` / `RevocAsyncOpenAI`. All
4
+ methods are thin wrappers over the admin-api REST surface (see
5
+ admin-api/app/routers/*) using httpx.AsyncClient under the hood.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime
11
+ from typing import Any
12
+
13
+ import httpx
14
+
15
+
16
+ class GovernanceClient:
17
+ """Async helper for querying audit data and controlling governance state.
18
+
19
+ Parameters
20
+ ----------
21
+ base_url:
22
+ Base URL of the REVOC dashboard / admin-api (e.g. ``http://revoc:3000``).
23
+ api_key:
24
+ Optional bearer token / API key used to authenticate against the
25
+ admin-api. Sent as ``Authorization: Bearer <api_key>`` when present.
26
+ """
27
+
28
+ def __init__(self, base_url: str, *, api_key: str | None = None) -> None:
29
+ self._base_url = base_url.rstrip("/")
30
+ self._api_key = api_key
31
+
32
+ def _headers(self) -> dict[str, str]:
33
+ headers: dict[str, str] = {}
34
+ if self._api_key:
35
+ headers["Authorization"] = f"Bearer {self._api_key}"
36
+ return headers
37
+
38
+ async def get_audit_events(self, **kwargs: Any) -> list[dict[str, Any]]:
39
+ """Fetch audit events matching the given query parameters.
40
+
41
+ Accepts the same query parameters as ``GET /api/v1/audit`` (e.g.
42
+ ``org_id``, ``decision``, ``limit``, ``offset``).
43
+ """
44
+ async with httpx.AsyncClient(base_url=self._base_url) as client:
45
+ resp = await client.get("/api/v1/audit", params=kwargs, headers=self._headers())
46
+ resp.raise_for_status()
47
+ data: list[dict[str, Any]] = resp.json()
48
+ return data
49
+
50
+ async def get_audit_stats(self, start_time: datetime, end_time: datetime) -> dict[str, Any]:
51
+ """Fetch aggregate audit statistics for a time window."""
52
+ params = {"start_time": start_time.isoformat(), "end_time": end_time.isoformat()}
53
+ async with httpx.AsyncClient(base_url=self._base_url) as client:
54
+ resp = await client.get("/api/v1/audit/stats", params=params, headers=self._headers())
55
+ resp.raise_for_status()
56
+ data: dict[str, Any] = resp.json()
57
+ return data
58
+
59
+ async def validate_policy(self, yaml_content: str) -> dict[str, Any]:
60
+ """Validate a policy bundle YAML manifest without activating it."""
61
+ async with httpx.AsyncClient(base_url=self._base_url) as client:
62
+ resp = await client.post(
63
+ "/api/v1/policies/validate",
64
+ json={"yaml_content": yaml_content},
65
+ headers=self._headers(),
66
+ )
67
+ resp.raise_for_status()
68
+ data: dict[str, Any] = resp.json()
69
+ return data
70
+
71
+ async def activate_kill_switch(self, scope: str, reason: str) -> dict[str, Any]:
72
+ """Activate the emergency kill-switch for the given scope.
73
+
74
+ ``scope`` is one of ``"global"``, ``"org"``, or ``"caller"``.
75
+ """
76
+ async with httpx.AsyncClient(base_url=self._base_url) as client:
77
+ resp = await client.post(
78
+ "/api/v1/emergency/kill-switch/activate",
79
+ json={"scope": scope, "reason": reason, "activated_by": "revoc-sdk"},
80
+ headers=self._headers(),
81
+ )
82
+ resp.raise_for_status()
83
+ data: dict[str, Any] = resp.json()
84
+ return data
85
+
86
+ async def deactivate_kill_switch(self, scope: str) -> dict[str, Any]:
87
+ """Deactivate the emergency kill-switch for the given scope."""
88
+ async with httpx.AsyncClient(base_url=self._base_url) as client:
89
+ resp = await client.post(
90
+ "/api/v1/emergency/kill-switch/deactivate",
91
+ json={"scope": scope},
92
+ headers=self._headers(),
93
+ )
94
+ resp.raise_for_status()
95
+ data: dict[str, Any] = resp.json()
96
+ return data
@@ -0,0 +1,138 @@
1
+ """revoc_sdk.mcp — helpers for governed Model Context Protocol tool calls.
2
+
3
+ REVOC inspects MCP traffic bidirectionally (SPEC.md Section 11, MCP Handler
4
+ 12-step lifecycle). When a `tools/call` request is blocked, the proxy returns
5
+ a JSON-RPC error response shaped like `McpBlockResponse`
6
+ (`{"error": {"code": -32603, "data": {"code": "REVOC_GOVERNANCE_BLOCK", ...}}}`).
7
+
8
+ This module provides a small helper that posts a governed `tools/call`
9
+ request through REVOC and raises :class:`RevocMcpError` when the call was
10
+ blocked, so MCP client code can handle governance outcomes uniformly with
11
+ the LLM-side exceptions in :mod:`revoc_sdk.exceptions`.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any
17
+
18
+ import httpx
19
+
20
+
21
+ class RevocMcpError(Exception):
22
+ """Raised when REVOC blocks an MCP `tools/call` request.
23
+
24
+ Mirrors the JSON-RPC error payload (`McpBlockResponse` /
25
+ `McpBlockData`, see SPEC.md Section 7.4): the upstream MCP server never
26
+ receives a blocked request (Rule 9 in Section 23).
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ *,
32
+ message: str,
33
+ code: str,
34
+ rules: list[str],
35
+ trace_id: str,
36
+ dashboard_url: str,
37
+ ) -> None:
38
+ self.message = message
39
+ self.code = code
40
+ self.rules = rules
41
+ self.trace_id = trace_id
42
+ self.dashboard_url = dashboard_url
43
+ super().__init__(str(self))
44
+
45
+ def __str__(self) -> str:
46
+ return (
47
+ f"REVOC blocked MCP tool call [{self.code}]: {self.message}\n"
48
+ f"Trace: {self.dashboard_url}"
49
+ )
50
+
51
+
52
+ class RevocMcpClient:
53
+ """Stateful client for governed MCP `tools/call` requests through REVOC.
54
+
55
+ Wraps :func:`governed_tool_call` with a persistent base URL, default
56
+ headers, and an auto-incrementing JSON-RPC request id, so callers can
57
+ issue repeated governed tool calls without repeating boilerplate.
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ base_url: str,
63
+ *,
64
+ headers: dict[str, str] | None = None,
65
+ ) -> None:
66
+ self.base_url = base_url
67
+ self.headers = headers or {}
68
+ self._next_id = 1
69
+
70
+ def _request_id(self) -> str:
71
+ request_id = str(self._next_id)
72
+ self._next_id += 1
73
+ return request_id
74
+
75
+ async def call_tool(
76
+ self,
77
+ tool_name: str,
78
+ arguments: dict[str, Any],
79
+ *,
80
+ server_id: str | None = None,
81
+ ) -> dict[str, Any]:
82
+ """Issue a governed `tools/call` request.
83
+
84
+ Raises :class:`RevocMcpError` if REVOC blocks the call before it
85
+ ever reaches the upstream MCP server (SPEC.md §23 Rule 9).
86
+ """
87
+ return await governed_tool_call(
88
+ self.base_url,
89
+ tool_name=tool_name,
90
+ arguments=arguments,
91
+ server_id=server_id,
92
+ request_id=self._request_id(),
93
+ headers=self.headers,
94
+ )
95
+
96
+
97
+ async def governed_tool_call(
98
+ base_url: str,
99
+ *,
100
+ tool_name: str,
101
+ arguments: dict[str, Any],
102
+ server_id: str | None = None,
103
+ request_id: str = "1",
104
+ headers: dict[str, str] | None = None,
105
+ ) -> dict[str, Any]:
106
+ """POST a `tools/call` JSON-RPC request through the REVOC MCP endpoint.
107
+
108
+ Returns the parsed JSON-RPC result on success. Raises
109
+ :class:`RevocMcpError` if REVOC blocked the call before it ever reached
110
+ the upstream MCP server.
111
+ """
112
+ payload: dict[str, Any] = {
113
+ "jsonrpc": "2.0",
114
+ "id": request_id,
115
+ "method": "tools/call",
116
+ "params": {"name": tool_name, "arguments": arguments},
117
+ }
118
+ if server_id is not None:
119
+ payload["params"]["server_id"] = server_id
120
+
121
+ async with httpx.AsyncClient(base_url=base_url) as client:
122
+ resp = await client.post("/mcp", json=payload, headers=headers or {})
123
+ resp.raise_for_status()
124
+ body: dict[str, Any] = resp.json()
125
+
126
+ error = body.get("error")
127
+ if isinstance(error, dict):
128
+ raw_data = error.get("data")
129
+ data: dict[str, Any] = raw_data if isinstance(raw_data, dict) else {}
130
+ if data.get("code") == "REVOC_GOVERNANCE_BLOCK":
131
+ raise RevocMcpError(
132
+ message=error.get("message", ""),
133
+ code=data.get("code", "REVOC_GOVERNANCE_BLOCK"),
134
+ rules=data.get("rules") or [],
135
+ trace_id=data.get("trace_id", ""),
136
+ dashboard_url=data.get("dashboard_url", ""),
137
+ )
138
+ return body
@@ -0,0 +1,76 @@
1
+ """revoc_sdk.patch — zero-code-change activation for existing OpenAI clients.
2
+
3
+ Calling :func:`patch` once at process startup monkey-patches
4
+ ``openai.OpenAI.__init__`` and ``openai.AsyncOpenAI.__init__`` so that *any*
5
+ existing code that constructs an ``OpenAI()`` or ``AsyncOpenAI()`` client is
6
+ transparently routed through REVOC — provided the ``REVOC_BASE_URL``
7
+ environment variable is set. No source changes required (SPEC.md Section 2).
8
+
9
+ export REVOC_BASE_URL=http://revoc:8080
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from typing import Any
16
+
17
+ import openai
18
+
19
+ _PATCHED_ATTR = "_revoc_patched"
20
+ _ORIGINAL_SYNC_INIT_ATTR = "_revoc_original_sync_init"
21
+ _ORIGINAL_ASYNC_INIT_ATTR = "_revoc_original_async_init"
22
+
23
+
24
+ def patch() -> None:
25
+ """Monkey-patch ``openai.OpenAI``/``openai.AsyncOpenAI`` to honor REVOC_BASE_URL.
26
+
27
+ Idempotent — calling this more than once is a no-op after the first call.
28
+ If ``REVOC_BASE_URL`` is not set at call time, this function does nothing
29
+ (existing client construction behaves exactly as before).
30
+ """
31
+ revoc_url = os.environ.get("REVOC_BASE_URL")
32
+ if not revoc_url:
33
+ return
34
+
35
+ if getattr(openai.OpenAI, _PATCHED_ATTR, False):
36
+ return
37
+
38
+ _original_sync_init = openai.OpenAI.__init__
39
+ _original_async_init = openai.AsyncOpenAI.__init__
40
+
41
+ def _patched_sync_init(self: openai.OpenAI, *args: Any, **kwargs: Any) -> None:
42
+ kwargs.setdefault("base_url", os.environ.get("REVOC_BASE_URL"))
43
+ _original_sync_init(self, *args, **kwargs)
44
+
45
+ def _patched_async_init(self: openai.AsyncOpenAI, *args: Any, **kwargs: Any) -> None:
46
+ kwargs.setdefault("base_url", os.environ.get("REVOC_BASE_URL"))
47
+ _original_async_init(self, *args, **kwargs)
48
+
49
+ setattr(openai.OpenAI, _ORIGINAL_SYNC_INIT_ATTR, _original_sync_init)
50
+ setattr(openai.AsyncOpenAI, _ORIGINAL_ASYNC_INIT_ATTR, _original_async_init)
51
+ openai.OpenAI.__init__ = _patched_sync_init # type: ignore[method-assign]
52
+ openai.AsyncOpenAI.__init__ = _patched_async_init # type: ignore[method-assign]
53
+ setattr(openai.OpenAI, _PATCHED_ATTR, True)
54
+ setattr(openai.AsyncOpenAI, _PATCHED_ATTR, True)
55
+
56
+
57
+ def unpatch() -> None:
58
+ """Reverse the effect of :func:`patch`.
59
+
60
+ Restores ``openai.OpenAI.__init__`` and ``openai.AsyncOpenAI.__init__`` to
61
+ their pre-patch implementations. Idempotent — calling this when no patch
62
+ is active (or more than once) is a no-op.
63
+ """
64
+ if not getattr(openai.OpenAI, _PATCHED_ATTR, False):
65
+ return
66
+
67
+ original_sync_init = getattr(openai.OpenAI, _ORIGINAL_SYNC_INIT_ATTR)
68
+ original_async_init = getattr(openai.AsyncOpenAI, _ORIGINAL_ASYNC_INIT_ATTR)
69
+
70
+ openai.OpenAI.__init__ = original_sync_init # type: ignore[method-assign]
71
+ openai.AsyncOpenAI.__init__ = original_async_init # type: ignore[method-assign]
72
+
73
+ delattr(openai.OpenAI, _PATCHED_ATTR)
74
+ delattr(openai.AsyncOpenAI, _PATCHED_ATTR)
75
+ delattr(openai.OpenAI, _ORIGINAL_SYNC_INIT_ATTR)
76
+ delattr(openai.AsyncOpenAI, _ORIGINAL_ASYNC_INIT_ATTR)
@@ -0,0 +1,11 @@
1
+ """tests/conftest.py — shared fixtures for revoc-sdk tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+
8
+ @pytest.fixture(autouse=True)
9
+ def _clean_env(monkeypatch: pytest.MonkeyPatch) -> None:
10
+ """Ensure REVOC_BASE_URL doesn't leak between tests."""
11
+ monkeypatch.delenv("REVOC_BASE_URL", raising=False)
@@ -0,0 +1,141 @@
1
+ """tests/test_client.py — RevocOpenAI / RevocAsyncOpenAI behavior."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+ import pytest
7
+ from openai import AsyncOpenAI, OpenAI
8
+
9
+ from revoc_sdk import (
10
+ RevocAsyncOpenAI,
11
+ RevocDepthExceededError,
12
+ RevocGovernanceError,
13
+ RevocKillSwitchError,
14
+ RevocOpenAI,
15
+ patch,
16
+ )
17
+ from revoc_sdk.client import _async_response_hook, _response_hook
18
+
19
+ _REVOC_URL = "http://revoc.local:8080"
20
+
21
+
22
+ def test_base_url_set_from_arg() -> None:
23
+ client = RevocOpenAI(api_key="sk-test", revoc_base_url=_REVOC_URL)
24
+ assert str(client.base_url).rstrip("/") == _REVOC_URL
25
+
26
+
27
+ def test_base_url_from_env_var(monkeypatch: pytest.MonkeyPatch) -> None:
28
+ monkeypatch.setenv("REVOC_BASE_URL", _REVOC_URL)
29
+ client = RevocOpenAI(api_key="sk-test")
30
+ assert str(client.base_url).rstrip("/") == _REVOC_URL
31
+
32
+
33
+ def test_is_instance_of_openai() -> None:
34
+ client = RevocOpenAI(api_key="sk-test", revoc_base_url=_REVOC_URL)
35
+ assert isinstance(client, OpenAI)
36
+
37
+
38
+ def test_async_client_is_instance_of_async_openai() -> None:
39
+ client = RevocAsyncOpenAI(api_key="sk-test", revoc_base_url=_REVOC_URL)
40
+ assert isinstance(client, AsyncOpenAI)
41
+
42
+
43
+ def _build_response(status_code: int, payload: dict[str, object]) -> httpx.Response:
44
+ request = httpx.Request("POST", "http://revoc.local:8080/v1/chat/completions")
45
+ return httpx.Response(status_code, json=payload, request=request)
46
+
47
+
48
+ def test_governance_block_raises_error() -> None:
49
+ payload = {
50
+ "error": {
51
+ "code": "REVOC_GOVERNANCE_BLOCK",
52
+ "message": "Prompt injection detected",
53
+ "blocked_by": "sec_prompt_injection_v1",
54
+ "rules": ["sec_prompt_injection_v1"],
55
+ "trace_id": "01911f6e-0000-7000-8000-000000000000",
56
+ "dashboard_url": "http://localhost:3000/traces/01911f6e",
57
+ "documentation_url": "https://revoc.io/docs/policies/sec_prompt_injection_v1",
58
+ }
59
+ }
60
+ response = _build_response(403, payload)
61
+ with pytest.raises(RevocGovernanceError) as exc_info:
62
+ _response_hook(response)
63
+ err = exc_info.value
64
+ assert err.blocked_by == "sec_prompt_injection_v1"
65
+ assert err.rules == ["sec_prompt_injection_v1"]
66
+ assert err.trace_id == "01911f6e-0000-7000-8000-000000000000"
67
+
68
+
69
+ def test_kill_switch_raises_kill_switch_error() -> None:
70
+ payload = {
71
+ "error": {
72
+ "code": "REVOC_KILL_SWITCH_ACTIVE",
73
+ "message": "All AI traffic suspended by emergency stop.",
74
+ "trace_id": "trace-123",
75
+ "dashboard_url": "http://localhost:3000/emergency",
76
+ }
77
+ }
78
+ response = _build_response(503, payload)
79
+ with pytest.raises(RevocKillSwitchError):
80
+ _response_hook(response)
81
+
82
+
83
+ def test_depth_raises_depth_error() -> None:
84
+ payload = {
85
+ "error": {
86
+ "code": "REVOC_GOVERNANCE_DEPTH_EXCEEDED",
87
+ "message": "Governance depth limit (10/10) reached. Possible infinite agent loop.",
88
+ "trace_id": "trace-456",
89
+ "dashboard_url": "http://localhost:3000/traces/trace-456",
90
+ }
91
+ }
92
+ response = _build_response(503, payload)
93
+ with pytest.raises(RevocDepthExceededError) as exc_info:
94
+ _response_hook(response)
95
+ assert exc_info.value.depth == 10
96
+ assert exc_info.value.max_depth == 10
97
+
98
+
99
+ def test_error_str_contains_dashboard_url() -> None:
100
+ err = RevocGovernanceError(
101
+ reason="Blocked: secret detected",
102
+ decision="block",
103
+ blocked_by="secret_block_v1",
104
+ rules=["secret_block_v1"],
105
+ trace_id="trace-789",
106
+ dashboard_url="http://localhost:3000/traces/trace-789",
107
+ )
108
+ text = str(err)
109
+ assert "http://localhost:3000/traces/trace-789" in text
110
+ assert "secret_block_v1" in text
111
+
112
+
113
+ def test_patch_injects_base_url(monkeypatch: pytest.MonkeyPatch) -> None:
114
+ monkeypatch.setenv("REVOC_BASE_URL", _REVOC_URL)
115
+ patch()
116
+ client = OpenAI(api_key="sk-test")
117
+ assert str(client.base_url).rstrip("/") == _REVOC_URL
118
+
119
+
120
+ @pytest.mark.asyncio
121
+ async def test_async_response_hook_raises_governance_error() -> None:
122
+ payload = {
123
+ "error": {
124
+ "code": "REVOC_GOVERNANCE_BLOCK",
125
+ "message": "Blocked",
126
+ "blocked_by": "secret_block_v1",
127
+ "rules": ["secret_block_v1"],
128
+ "trace_id": "trace-async",
129
+ "dashboard_url": "http://localhost:3000/traces/trace-async",
130
+ }
131
+ }
132
+ response = _build_response(403, payload)
133
+ with pytest.raises(RevocGovernanceError):
134
+ await _async_response_hook(response)
135
+
136
+
137
+ def test_non_revoc_error_passes_through() -> None:
138
+ """Upstream provider errors (forwarded verbatim) must not raise REVOC exceptions."""
139
+ payload = {"error": {"message": "invalid_api_key", "type": "invalid_request_error"}}
140
+ response = _build_response(401, payload)
141
+ _response_hook(response) # should not raise
@@ -0,0 +1,83 @@
1
+ """tests/test_governance.py — GovernanceClient HTTP wrapper behavior."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+
7
+ import httpx
8
+ import pytest
9
+
10
+ from revoc_sdk import GovernanceClient
11
+
12
+ # Captured before any monkeypatching replaces httpx.AsyncClient, so the
13
+ # patched factory below can construct a real client against a mock transport
14
+ # without recursing into itself.
15
+ _RealAsyncClient = httpx.AsyncClient
16
+
17
+
18
+ @pytest.fixture
19
+ def governance_client() -> GovernanceClient:
20
+ return GovernanceClient(base_url="http://revoc.local:3000", api_key="test-admin-key")
21
+
22
+
23
+ @pytest.mark.asyncio
24
+ async def test_get_audit_stats_sends_authorization_header(
25
+ governance_client: GovernanceClient, monkeypatch: pytest.MonkeyPatch
26
+ ) -> None:
27
+ captured: dict[str, httpx.Request] = {}
28
+
29
+ def handler(request: httpx.Request) -> httpx.Response:
30
+ captured["request"] = request
31
+ return httpx.Response(200, json={"total": 0, "blocked": 0, "redacted": 0, "allowed": 0})
32
+
33
+ transport = httpx.MockTransport(handler)
34
+ monkeypatch.setattr(
35
+ httpx,
36
+ "AsyncClient",
37
+ lambda **kw: _RealAsyncClient(transport=transport, base_url=kw.get("base_url", "")),
38
+ )
39
+
40
+ await governance_client.get_audit_stats(
41
+ datetime(2026, 6, 6, tzinfo=UTC), datetime(2026, 6, 7, tzinfo=UTC)
42
+ )
43
+ assert captured["request"].headers["authorization"] == "Bearer test-admin-key"
44
+ assert captured["request"].url.path == "/api/v1/audit/stats"
45
+
46
+
47
+ @pytest.mark.asyncio
48
+ async def test_validate_policy_returns_json(monkeypatch: pytest.MonkeyPatch) -> None:
49
+ client = GovernanceClient(base_url="http://revoc.local:3000")
50
+
51
+ def handler(request: httpx.Request) -> httpx.Response:
52
+ return httpx.Response(200, json={"is_valid": True, "errors": []})
53
+
54
+ transport = httpx.MockTransport(handler)
55
+ monkeypatch.setattr(
56
+ httpx,
57
+ "AsyncClient",
58
+ lambda **kw: _RealAsyncClient(transport=transport, base_url=kw.get("base_url", "")),
59
+ )
60
+
61
+ result = await client.validate_policy("apiVersion: revoc.io/v1\nkind: PolicyManifest\n")
62
+ assert result["is_valid"] is True
63
+
64
+
65
+ @pytest.mark.asyncio
66
+ async def test_activate_kill_switch_posts_scope(monkeypatch: pytest.MonkeyPatch) -> None:
67
+ client = GovernanceClient(base_url="http://revoc.local:3000")
68
+ captured: dict[str, httpx.Request] = {}
69
+
70
+ def handler(request: httpx.Request) -> httpx.Response:
71
+ captured["request"] = request
72
+ return httpx.Response(200, json={"scope": "global", "action": "activate"})
73
+
74
+ transport = httpx.MockTransport(handler)
75
+ monkeypatch.setattr(
76
+ httpx,
77
+ "AsyncClient",
78
+ lambda **kw: _RealAsyncClient(transport=transport, base_url=kw.get("base_url", "")),
79
+ )
80
+
81
+ result = await client.activate_kill_switch("global", "incident-123")
82
+ assert result["action"] == "activate"
83
+ assert captured["request"].url.path == "/api/v1/emergency/kill-switch/activate"