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.
- revoc_sdk-1.0.0/.gitignore +159 -0
- revoc_sdk-1.0.0/PKG-INFO +103 -0
- revoc_sdk-1.0.0/README.md +72 -0
- revoc_sdk-1.0.0/pyproject.toml +61 -0
- revoc_sdk-1.0.0/revoc_sdk/__init__.py +38 -0
- revoc_sdk-1.0.0/revoc_sdk/client.py +194 -0
- revoc_sdk-1.0.0/revoc_sdk/exceptions.py +91 -0
- revoc_sdk-1.0.0/revoc_sdk/governance.py +96 -0
- revoc_sdk-1.0.0/revoc_sdk/mcp.py +138 -0
- revoc_sdk-1.0.0/revoc_sdk/patch.py +76 -0
- revoc_sdk-1.0.0/tests/conftest.py +11 -0
- revoc_sdk-1.0.0/tests/test_client.py +141 -0
- revoc_sdk-1.0.0/tests/test_governance.py +83 -0
|
@@ -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/**
|
revoc_sdk-1.0.0/PKG-INFO
ADDED
|
@@ -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"
|