mobiusguard 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mobiusguard/__init__.py +6 -0
- mobiusguard/apis.py +171 -0
- mobiusguard/app.py +186 -0
- mobiusguard/audit.py +100 -0
- mobiusguard/classifier.py +133 -0
- mobiusguard/cli.py +58 -0
- mobiusguard/config.py +167 -0
- mobiusguard/guard.py +116 -0
- mobiusguard/hooks.py +56 -0
- mobiusguard/normalize.py +123 -0
- mobiusguard/outbound.py +317 -0
- mobiusguard/py.typed +0 -0
- mobiusguard/rules.py +114 -0
- mobiusguard/sse.py +63 -0
- mobiusguard-0.1.0.dist-info/METADATA +77 -0
- mobiusguard-0.1.0.dist-info/RECORD +20 -0
- mobiusguard-0.1.0.dist-info/WHEEL +4 -0
- mobiusguard-0.1.0.dist-info/entry_points.txt +2 -0
- mobiusguard-0.1.0.dist-info/licenses/LICENSE +202 -0
- mobiusguard-0.1.0.dist-info/licenses/NOTICE +9 -0
mobiusguard/__init__.py
ADDED
mobiusguard/apis.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""The three APIs the proxy speaks: where their text is, and how they report an error.
|
|
2
|
+
|
|
3
|
+
The proxy never rebuilds a request. It reads the text fields the model will see (for the
|
|
4
|
+
request path) and the text and argument deltas the model writes (for the response path),
|
|
5
|
+
and passes everything else through as it came.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any, Literal, cast
|
|
12
|
+
|
|
13
|
+
from .config import Config, Upstream
|
|
14
|
+
|
|
15
|
+
Role = str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class Text:
|
|
20
|
+
"""One piece of text the model will read, and where it is in the request."""
|
|
21
|
+
|
|
22
|
+
role: Role # system | user | assistant | tool | developer
|
|
23
|
+
text: str
|
|
24
|
+
where: str # e.g. "messages[2].content[0]"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
Json = dict[str, Any]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _as_dict(v: object) -> Json:
|
|
31
|
+
return cast("Json", v) if isinstance(v, dict) else {}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _as_list(v: object) -> list[Any]:
|
|
35
|
+
return cast("list[Any]", v) if isinstance(v, list) else []
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Api:
|
|
39
|
+
name: Literal["chat", "responses", "anthropic"]
|
|
40
|
+
path: str # route on the proxy
|
|
41
|
+
upstream_path: str # appended to the upstream base URL
|
|
42
|
+
|
|
43
|
+
def upstream(self, cfg: Config) -> Upstream:
|
|
44
|
+
return cfg.openai
|
|
45
|
+
|
|
46
|
+
def texts(self, body: Json) -> list[Text]:
|
|
47
|
+
raise NotImplementedError
|
|
48
|
+
|
|
49
|
+
def is_stream(self, body: Json) -> bool:
|
|
50
|
+
return body.get("stream") is True
|
|
51
|
+
|
|
52
|
+
def error(self, status: int, message: str, code: str, details: Json | None = None) -> Json:
|
|
53
|
+
"""An error body in this API's own format (OpenAI style by default)."""
|
|
54
|
+
err: Json = {"message": message, "type": _openai_type(status), "param": None, "code": code}
|
|
55
|
+
body: Json = {"error": err}
|
|
56
|
+
if details:
|
|
57
|
+
err["mobiusguard"] = details
|
|
58
|
+
return body
|
|
59
|
+
|
|
60
|
+
def set_key(self, headers: dict[str, str], key: str) -> None:
|
|
61
|
+
headers.pop("x-api-key", None)
|
|
62
|
+
headers["authorization"] = f"Bearer {key}"
|
|
63
|
+
|
|
64
|
+
def caller_key(self, headers: dict[str, str]) -> str:
|
|
65
|
+
auth = headers.get("authorization", "")
|
|
66
|
+
return auth[7:].strip() if auth.lower().startswith("bearer ") else ""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _openai_type(status: int) -> str:
|
|
70
|
+
names = {400: "invalid_request_error", 401: "authentication_error",
|
|
71
|
+
403: "permission_error", 413: "invalid_request_error"} # fmt: skip
|
|
72
|
+
return names.get(status, "api_error")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _parts(content: object, where: str, role: Role, kinds: tuple[str, ...]) -> list[Text]:
|
|
76
|
+
"""Text from a string or a list of typed parts ({"type": "text", "text": ...})."""
|
|
77
|
+
if isinstance(content, str):
|
|
78
|
+
return [Text(role, content, where)] if content else []
|
|
79
|
+
out: list[Text] = []
|
|
80
|
+
for j, part in enumerate(_as_list(content)):
|
|
81
|
+
p = _as_dict(part)
|
|
82
|
+
if p.get("type") in kinds and isinstance(p.get("text"), str) and p["text"]:
|
|
83
|
+
out.append(Text(role, p["text"], f"{where}[{j}]"))
|
|
84
|
+
return out
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ChatCompletions(Api):
|
|
88
|
+
name = "chat"
|
|
89
|
+
path = "/v1/chat/completions"
|
|
90
|
+
upstream_path = "chat/completions"
|
|
91
|
+
|
|
92
|
+
def texts(self, body: Json) -> list[Text]:
|
|
93
|
+
out: list[Text] = []
|
|
94
|
+
for i, msg in enumerate(_as_list(body.get("messages"))):
|
|
95
|
+
m = _as_dict(msg)
|
|
96
|
+
role = str(m.get("role", "user"))
|
|
97
|
+
out += _parts(m.get("content"), f"messages[{i}].content", role, ("text",))
|
|
98
|
+
return out
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Responses(Api):
|
|
102
|
+
name = "responses"
|
|
103
|
+
path = "/v1/responses"
|
|
104
|
+
upstream_path = "responses"
|
|
105
|
+
|
|
106
|
+
def texts(self, body: Json) -> list[Text]:
|
|
107
|
+
out: list[Text] = []
|
|
108
|
+
if isinstance(body.get("instructions"), str) and body["instructions"]:
|
|
109
|
+
out.append(Text("system", body["instructions"], "instructions"))
|
|
110
|
+
inp = body.get("input")
|
|
111
|
+
if isinstance(inp, str):
|
|
112
|
+
return [*out, Text("user", inp, "input")] if inp else out
|
|
113
|
+
kinds = ("input_text", "output_text", "text")
|
|
114
|
+
for i, item in enumerate(_as_list(inp)):
|
|
115
|
+
it = _as_dict(item)
|
|
116
|
+
where = f"input[{i}]"
|
|
117
|
+
if it.get("type") == "function_call_output":
|
|
118
|
+
output = it.get("output")
|
|
119
|
+
if isinstance(output, str) and output:
|
|
120
|
+
out.append(Text("tool", output, f"{where}.output"))
|
|
121
|
+
else:
|
|
122
|
+
out += _parts(output, f"{where}.output", "tool", kinds)
|
|
123
|
+
elif "content" in it:
|
|
124
|
+
out += _parts(
|
|
125
|
+
it.get("content"), f"{where}.content", str(it.get("role", "user")), kinds
|
|
126
|
+
)
|
|
127
|
+
return out
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class AnthropicMessages(Api):
|
|
131
|
+
name = "anthropic"
|
|
132
|
+
path = "/v1/messages"
|
|
133
|
+
upstream_path = "messages"
|
|
134
|
+
|
|
135
|
+
def upstream(self, cfg: Config) -> Upstream:
|
|
136
|
+
return cfg.anthropic
|
|
137
|
+
|
|
138
|
+
def texts(self, body: Json) -> list[Text]:
|
|
139
|
+
out = _parts(body.get("system"), "system", "system", ("text",))
|
|
140
|
+
for i, msg in enumerate(_as_list(body.get("messages"))):
|
|
141
|
+
m = _as_dict(msg)
|
|
142
|
+
role = str(m.get("role", "user"))
|
|
143
|
+
content = m.get("content")
|
|
144
|
+
out += _parts(content, f"messages[{i}].content", role, ("text",))
|
|
145
|
+
for j, block in enumerate(_as_list(content)):
|
|
146
|
+
b = _as_dict(block)
|
|
147
|
+
if b.get("type") == "tool_result":
|
|
148
|
+
out += _parts(b.get("content"), f"messages[{i}].content[{j}].content", "tool",
|
|
149
|
+
("text",)) # fmt: skip
|
|
150
|
+
return out
|
|
151
|
+
|
|
152
|
+
def error(self, status: int, message: str, code: str, details: Json | None = None) -> Json:
|
|
153
|
+
err: Json = {"type": _anthropic_type(status), "message": message}
|
|
154
|
+
if details:
|
|
155
|
+
err["mobiusguard"] = {"code": code, **details}
|
|
156
|
+
return {"type": "error", "error": err}
|
|
157
|
+
|
|
158
|
+
def set_key(self, headers: dict[str, str], key: str) -> None:
|
|
159
|
+
headers.pop("authorization", None)
|
|
160
|
+
headers["x-api-key"] = key
|
|
161
|
+
|
|
162
|
+
def caller_key(self, headers: dict[str, str]) -> str:
|
|
163
|
+
return headers.get("x-api-key", "") or super().caller_key(headers)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _anthropic_type(status: int) -> str:
|
|
167
|
+
return {400: "invalid_request_error", 401: "authentication_error",
|
|
168
|
+
403: "permission_error", 413: "request_too_large"}.get(status, "api_error") # fmt: skip
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
APIS: tuple[Api, ...] = (ChatCompletions(), Responses(), AnthropicMessages())
|
mobiusguard/app.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""The proxy: one handler for the three APIs, a pass-through for model lists.
|
|
2
|
+
|
|
3
|
+
Order for each request: caller check, size and JSON check, the request check (rules,
|
|
4
|
+
classifier, policy), then the upstream call; the reply is redacted on its way back. A block
|
|
5
|
+
or an error is answered in the API's own error format.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable
|
|
13
|
+
from contextlib import asynccontextmanager
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
from starlette.applications import Starlette
|
|
17
|
+
from starlette.background import BackgroundTask
|
|
18
|
+
from starlette.requests import Request
|
|
19
|
+
from starlette.responses import JSONResponse, Response, StreamingResponse
|
|
20
|
+
from starlette.routing import Route
|
|
21
|
+
|
|
22
|
+
from . import sse
|
|
23
|
+
from .apis import APIS, Api, Json
|
|
24
|
+
from .config import Config
|
|
25
|
+
|
|
26
|
+
# Headers passed on to the upstream; everything else (cookies, host, hop-by-hop) is dropped.
|
|
27
|
+
FORWARD = {
|
|
28
|
+
"authorization", "x-api-key", "anthropic-version", "anthropic-beta", "openai-organization",
|
|
29
|
+
"openai-project", "openai-beta", "user-agent", "accept", "idempotency-key",
|
|
30
|
+
} # fmt: skip
|
|
31
|
+
# Headers passed back from the upstream.
|
|
32
|
+
RETURN_PREFIXES = ("x-request-id", "request-id", "openai-", "anthropic-", "x-ratelimit-",
|
|
33
|
+
"retry-after") # fmt: skip
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Hooks:
|
|
37
|
+
"""What runs inside a request. Part 3a passes everything through; the guard (3b) and
|
|
38
|
+
the redaction (3c) replace these."""
|
|
39
|
+
|
|
40
|
+
async def check(self, api: Api, body: Json, request: Request, state: Json) -> Json | None:
|
|
41
|
+
"""None to forward the request, or ``{"status", "message", "code", "details"}``."""
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
def rewrite_json(self, api: Api, body: Json, state: Json) -> Json:
|
|
45
|
+
return body
|
|
46
|
+
|
|
47
|
+
def rewrite_events(
|
|
48
|
+
self, api: Api, events: AsyncIterator[sse.Event], state: Json
|
|
49
|
+
) -> AsyncIterator[sse.Event]:
|
|
50
|
+
return events
|
|
51
|
+
|
|
52
|
+
def finish(self, api: Api, state: Json) -> None:
|
|
53
|
+
"""Called once the reply has been sent (audit)."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _forward_headers(request: Request, api: Api, cfg: Config) -> dict[str, str]:
|
|
57
|
+
headers = {k.lower(): v for k, v in request.headers.items()}
|
|
58
|
+
out = {k: v for k, v in headers.items() if k in FORWARD or k.startswith("x-stainless-")}
|
|
59
|
+
key = api.upstream(cfg).api_key()
|
|
60
|
+
if key:
|
|
61
|
+
api.set_key(out, key)
|
|
62
|
+
out["accept-encoding"] = "identity" # we read the stream as it arrives
|
|
63
|
+
out["content-type"] = "application/json"
|
|
64
|
+
return out
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _return_headers(resp: httpx.Response) -> dict[str, str]:
|
|
68
|
+
return {k: v for k, v in resp.headers.items() if k.lower().startswith(RETURN_PREFIXES)}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def create_app(
|
|
72
|
+
cfg: Config, *, hooks: Hooks | None = None, transport: httpx.AsyncBaseTransport | None = None
|
|
73
|
+
) -> Starlette:
|
|
74
|
+
hooks = hooks or Hooks()
|
|
75
|
+
clients: dict[str, httpx.AsyncClient] = {}
|
|
76
|
+
|
|
77
|
+
@asynccontextmanager
|
|
78
|
+
async def lifespan(_: Starlette) -> AsyncGenerator[None]:
|
|
79
|
+
clients["http"] = httpx.AsyncClient(
|
|
80
|
+
timeout=httpx.Timeout(cfg.limits.timeout_seconds, connect=10), transport=transport
|
|
81
|
+
)
|
|
82
|
+
try:
|
|
83
|
+
yield
|
|
84
|
+
finally:
|
|
85
|
+
await clients["http"].aclose()
|
|
86
|
+
|
|
87
|
+
def fail(
|
|
88
|
+
api: Api, status: int, message: str, code: str, details: Json | None = None
|
|
89
|
+
) -> Response:
|
|
90
|
+
return JSONResponse(api.error(status, message, code, details), status_code=status)
|
|
91
|
+
|
|
92
|
+
def handler(api: Api) -> Callable[[Request], Awaitable[Response]]:
|
|
93
|
+
async def handle(request: Request) -> Response:
|
|
94
|
+
state: Json = {"started": time.perf_counter(), "api": api.name}
|
|
95
|
+
if cfg.client_tokens:
|
|
96
|
+
headers = {k.lower(): v for k, v in request.headers.items()}
|
|
97
|
+
if api.caller_key(headers) not in cfg.client_tokens:
|
|
98
|
+
return fail(api, 401, "Missing or unknown Mobius Guard client token.",
|
|
99
|
+
"unauthorized") # fmt: skip
|
|
100
|
+
raw = await request.body()
|
|
101
|
+
if len(raw) > cfg.limits.max_body_bytes:
|
|
102
|
+
return fail(api, 413, "Request body too large for Mobius Guard.", "too_large")
|
|
103
|
+
try:
|
|
104
|
+
body = json.loads(raw)
|
|
105
|
+
except json.JSONDecodeError:
|
|
106
|
+
return fail(api, 400, "Request body is not valid JSON.", "invalid_json")
|
|
107
|
+
if not isinstance(body, dict):
|
|
108
|
+
return fail(api, 400, "Request body must be a JSON object.", "invalid_json")
|
|
109
|
+
body_d: Json = body # pyright: ignore[reportUnknownVariableType]
|
|
110
|
+
state["model"] = str(body_d.get("model", ""))
|
|
111
|
+
state["stream"] = api.is_stream(body_d)
|
|
112
|
+
|
|
113
|
+
blocked = await hooks.check(api, body_d, request, state)
|
|
114
|
+
if blocked is not None:
|
|
115
|
+
state["decision"] = "block"
|
|
116
|
+
hooks.finish(api, state)
|
|
117
|
+
return fail(api, int(blocked["status"]), str(blocked["message"]),
|
|
118
|
+
str(blocked["code"]), blocked.get("details")) # fmt: skip
|
|
119
|
+
state.setdefault("decision", "allow")
|
|
120
|
+
|
|
121
|
+
up = api.upstream(cfg)
|
|
122
|
+
url = f"{up.base_url.rstrip('/')}/{api.upstream_path}"
|
|
123
|
+
client = clients["http"]
|
|
124
|
+
req = client.build_request("POST", url, content=raw,
|
|
125
|
+
headers=_forward_headers(request, api, cfg)) # fmt: skip
|
|
126
|
+
try:
|
|
127
|
+
resp = await client.send(req, stream=True)
|
|
128
|
+
except httpx.HTTPError as e:
|
|
129
|
+
state["upstream_error"] = type(e).__name__
|
|
130
|
+
hooks.finish(api, state)
|
|
131
|
+
return fail(
|
|
132
|
+
api, 502, f"Upstream unreachable: {type(e).__name__}.", "upstream_error"
|
|
133
|
+
)
|
|
134
|
+
state["upstream_status"] = resp.status_code
|
|
135
|
+
ctype = resp.headers.get("content-type", "")
|
|
136
|
+
|
|
137
|
+
if resp.status_code == 200 and ctype.startswith("text/event-stream"):
|
|
138
|
+
events = hooks.rewrite_events(api, sse.parse(resp.aiter_bytes()), state)
|
|
139
|
+
|
|
140
|
+
async def body_iter() -> AsyncIterator[bytes]:
|
|
141
|
+
async for ev in events:
|
|
142
|
+
yield ev.encode()
|
|
143
|
+
|
|
144
|
+
def done() -> None:
|
|
145
|
+
hooks.finish(api, state)
|
|
146
|
+
|
|
147
|
+
async def close() -> None:
|
|
148
|
+
await resp.aclose()
|
|
149
|
+
done()
|
|
150
|
+
|
|
151
|
+
return StreamingResponse(body_iter(), media_type="text/event-stream",
|
|
152
|
+
headers=_return_headers(resp),
|
|
153
|
+
background=BackgroundTask(close)) # fmt: skip
|
|
154
|
+
|
|
155
|
+
content = await resp.aread()
|
|
156
|
+
await resp.aclose()
|
|
157
|
+
if resp.status_code == 200 and ctype.startswith("application/json"):
|
|
158
|
+
try:
|
|
159
|
+
data = json.loads(content)
|
|
160
|
+
except json.JSONDecodeError:
|
|
161
|
+
data = None
|
|
162
|
+
if isinstance(data, dict):
|
|
163
|
+
out = hooks.rewrite_json(api, data, state) # pyright: ignore[reportUnknownArgumentType]
|
|
164
|
+
hooks.finish(api, state)
|
|
165
|
+
return JSONResponse(out, status_code=200, headers=_return_headers(resp))
|
|
166
|
+
hooks.finish(api, state)
|
|
167
|
+
# Errors and anything we don't understand go back as they came.
|
|
168
|
+
return Response(content, status_code=resp.status_code, media_type=ctype or None,
|
|
169
|
+
headers=_return_headers(resp)) # fmt: skip
|
|
170
|
+
|
|
171
|
+
return handle
|
|
172
|
+
|
|
173
|
+
async def models(request: Request) -> Response:
|
|
174
|
+
api = APIS[0]
|
|
175
|
+
url = f"{cfg.openai.base_url.rstrip('/')}/models"
|
|
176
|
+
resp = await clients["http"].get(url, headers=_forward_headers(request, api, cfg))
|
|
177
|
+
return Response(resp.content, status_code=resp.status_code,
|
|
178
|
+
media_type=resp.headers.get("content-type"),
|
|
179
|
+
headers=_return_headers(resp)) # fmt: skip
|
|
180
|
+
|
|
181
|
+
async def health(_: Request) -> Response:
|
|
182
|
+
return JSONResponse({"status": "ok"})
|
|
183
|
+
|
|
184
|
+
routes = [Route(api.path, handler(api), methods=["POST"]) for api in APIS]
|
|
185
|
+
routes += [Route("/v1/models", models, methods=["GET"]), Route("/healthz", health)]
|
|
186
|
+
return Starlette(routes=routes, lifespan=lifespan)
|
mobiusguard/audit.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""The audit log and telemetry: what was decided for each request, never the text by default.
|
|
2
|
+
|
|
3
|
+
One JSON line per request: when, which API and model, the decision and why (rule IDs,
|
|
4
|
+
severity, which view matched), the classifier score, what was redacted (pattern IDs and
|
|
5
|
+
counts), timings and a hash of the checked prompt. Prompt text is written only when
|
|
6
|
+
``[audit] include_text = true``. API keys are never written.
|
|
7
|
+
|
|
8
|
+
OpenTelemetry spans (GenAI semantic conventions) are emitted when ``opentelemetry-api`` is
|
|
9
|
+
installed (``mobiusguard[otel]``) and an SDK is configured by the host.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import importlib
|
|
15
|
+
import json
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from datetime import UTC, datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from .apis import Json
|
|
23
|
+
from .guard import Decision
|
|
24
|
+
from .outbound import Redactors
|
|
25
|
+
|
|
26
|
+
SYSTEM = {"chat": "openai", "responses": "openai", "anthropic": "anthropic"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def record(state: Json) -> Json:
|
|
30
|
+
"""The audit record for one request, from the state the handler collected."""
|
|
31
|
+
d = state.get("guard")
|
|
32
|
+
red = state.get("redactors")
|
|
33
|
+
out: Json = {
|
|
34
|
+
"time": datetime.now(UTC).isoformat(timespec="milliseconds"),
|
|
35
|
+
"api": state.get("api"),
|
|
36
|
+
"model": state.get("model", ""),
|
|
37
|
+
"stream": bool(state.get("stream")),
|
|
38
|
+
"decision": state.get("decision", "allow"),
|
|
39
|
+
"ms": round(
|
|
40
|
+
(time.perf_counter() - float(state.get("started", time.perf_counter()))) * 1000, 1
|
|
41
|
+
),
|
|
42
|
+
}
|
|
43
|
+
if isinstance(d, Decision):
|
|
44
|
+
out["reason"] = d.reason
|
|
45
|
+
out["rules"] = [{"id": h.rule, "severity": h.severity, "view": h.view, "where": h.where}
|
|
46
|
+
for h in d.hits] # fmt: skip
|
|
47
|
+
out["score"] = None if d.score is None else round(d.score, 4)
|
|
48
|
+
out["checked"] = d.checked
|
|
49
|
+
out["prompt_sha256"] = d.prompt_hash
|
|
50
|
+
if d.error:
|
|
51
|
+
out["error"] = d.error
|
|
52
|
+
if isinstance(red, Redactors):
|
|
53
|
+
out["redacted"] = dict(red.found)
|
|
54
|
+
for key in ("upstream_status", "upstream_error"):
|
|
55
|
+
if key in state:
|
|
56
|
+
out[key] = state[key]
|
|
57
|
+
if "texts" in state:
|
|
58
|
+
out["texts"] = state["texts"]
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class AuditLog:
|
|
63
|
+
def __init__(self, path: str) -> None:
|
|
64
|
+
self.path = Path(path) if path else None
|
|
65
|
+
self._lock = threading.Lock()
|
|
66
|
+
|
|
67
|
+
def write(self, rec: Json) -> None:
|
|
68
|
+
if self.path is None:
|
|
69
|
+
return
|
|
70
|
+
line = json.dumps(rec, ensure_ascii=False, default=str) + "\n"
|
|
71
|
+
with self._lock:
|
|
72
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
with self.path.open("a", encoding="utf-8") as f:
|
|
74
|
+
f.write(line)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def emit_span(rec: Json, started_ns: int) -> None:
|
|
78
|
+
"""A span for the request, if OpenTelemetry is installed; otherwise nothing."""
|
|
79
|
+
try:
|
|
80
|
+
trace: Any = importlib.import_module("opentelemetry.trace")
|
|
81
|
+
except ImportError:
|
|
82
|
+
return
|
|
83
|
+
tracer: Any = trace.get_tracer("mobiusguard")
|
|
84
|
+
api = str(rec.get("api", ""))
|
|
85
|
+
span: Any = tracer.start_span(f"mobiusguard {api}", start_time=started_ns)
|
|
86
|
+
attrs: Json = {
|
|
87
|
+
"gen_ai.operation.name": "chat",
|
|
88
|
+
"gen_ai.system": SYSTEM.get(api, api),
|
|
89
|
+
"gen_ai.request.model": rec.get("model", ""),
|
|
90
|
+
"mobiusguard.decision": rec.get("decision", ""),
|
|
91
|
+
"mobiusguard.rules": [r["id"] for r in rec.get("rules", [])],
|
|
92
|
+
"mobiusguard.redacted": sum(rec.get("redacted", {}).values()),
|
|
93
|
+
}
|
|
94
|
+
if rec.get("score") is not None:
|
|
95
|
+
attrs["mobiusguard.score"] = rec["score"]
|
|
96
|
+
if "upstream_status" in rec:
|
|
97
|
+
attrs["http.response.status_code"] = rec["upstream_status"]
|
|
98
|
+
for k, v in attrs.items():
|
|
99
|
+
span.set_attribute(k, v)
|
|
100
|
+
span.end()
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Optional pretrained prompt-injection classifiers (``mobiusguard[classifier]``).
|
|
2
|
+
|
|
3
|
+
Pinned to a commit. PIGuard ships its model class as remote code; we never run downloaded
|
|
4
|
+
code: the class is re-implemented below from the pinned ``modeling_piguard.py`` (a
|
|
5
|
+
DeBERTa-v2 encoder, the first token's hidden state, one linear layer) and only the weights
|
|
6
|
+
are downloaded. Scores are log-odds of the attack label (higher = more likely an attack).
|
|
7
|
+
The evaluation harness measures this same code.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import importlib
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class HFModel:
|
|
20
|
+
name: str
|
|
21
|
+
repo: str
|
|
22
|
+
revision: str
|
|
23
|
+
licence: str
|
|
24
|
+
positive_label: str
|
|
25
|
+
arch: str = "auto" # or "piguard"
|
|
26
|
+
max_length: int = 512
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
HF_MODELS = {
|
|
30
|
+
"protectai-v2": HFModel(
|
|
31
|
+
name="protectai-v2",
|
|
32
|
+
repo="protectai/deberta-v3-base-prompt-injection-v2",
|
|
33
|
+
revision="90c9989b1a342275dd0d1a95aad283c04e075671",
|
|
34
|
+
licence="Apache-2.0",
|
|
35
|
+
positive_label="INJECTION",
|
|
36
|
+
),
|
|
37
|
+
"piguard": HFModel(
|
|
38
|
+
name="piguard",
|
|
39
|
+
repo="leolee99/PIGuard",
|
|
40
|
+
revision="dd78b24e330193a22d2293ac66922dd4f982f563",
|
|
41
|
+
licence="MIT",
|
|
42
|
+
positive_label="injection",
|
|
43
|
+
arch="piguard",
|
|
44
|
+
),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _piguard_class(transformers: Any, torch: Any) -> Any:
|
|
49
|
+
outputs = importlib.import_module("transformers.modeling_outputs")
|
|
50
|
+
|
|
51
|
+
class PIGuard(transformers.DebertaV2ForSequenceClassification):
|
|
52
|
+
def __init__(self, config: Any) -> None:
|
|
53
|
+
super().__init__(config) # pyright: ignore[reportUnknownMemberType]
|
|
54
|
+
self.classifier = torch.nn.Linear(config.hidden_size, config.num_labels)
|
|
55
|
+
|
|
56
|
+
def forward(self, input_ids: Any, attention_mask: Any, **kwargs: Any) -> Any:
|
|
57
|
+
out = self.deberta(input_ids=input_ids, attention_mask=attention_mask)
|
|
58
|
+
logits = self.classifier(out.last_hidden_state[:, 0, :])
|
|
59
|
+
return outputs.SequenceClassifierOutput(logits=logits)
|
|
60
|
+
|
|
61
|
+
return PIGuard
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class HFClassifier:
|
|
65
|
+
def __init__(self, spec: HFModel, *, device: str = "cpu", batch_size: int = 32) -> None:
|
|
66
|
+
self.spec = spec
|
|
67
|
+
self.name = spec.name
|
|
68
|
+
self.device = device
|
|
69
|
+
self.batch_size = batch_size
|
|
70
|
+
self.torch: Any = importlib.import_module("torch")
|
|
71
|
+
self.transformers: Any = importlib.import_module("transformers")
|
|
72
|
+
tf = self.transformers
|
|
73
|
+
self.tokenizer: Any = tf.AutoTokenizer.from_pretrained(
|
|
74
|
+
spec.repo, revision=spec.revision, trust_remote_code=False
|
|
75
|
+
)
|
|
76
|
+
if spec.arch == "piguard":
|
|
77
|
+
config = tf.DebertaV2Config.from_pretrained(
|
|
78
|
+
spec.repo, revision=spec.revision, trust_remote_code=False
|
|
79
|
+
)
|
|
80
|
+
cls = _piguard_class(tf, self.torch)
|
|
81
|
+
model = cls.from_pretrained(
|
|
82
|
+
spec.repo, revision=spec.revision, config=config, trust_remote_code=False
|
|
83
|
+
)
|
|
84
|
+
else:
|
|
85
|
+
model = tf.AutoModelForSequenceClassification.from_pretrained(
|
|
86
|
+
spec.repo, revision=spec.revision, trust_remote_code=False
|
|
87
|
+
)
|
|
88
|
+
self.model: Any = model.to(device).eval()
|
|
89
|
+
labels = {str(v).lower(): int(k) for k, v in self.model.config.id2label.items()}
|
|
90
|
+
self.positive = labels[spec.positive_label.lower()]
|
|
91
|
+
|
|
92
|
+
def info(self) -> dict[str, str]:
|
|
93
|
+
t = self.torch
|
|
94
|
+
info = {
|
|
95
|
+
"repo": self.spec.repo,
|
|
96
|
+
"revision": self.spec.revision,
|
|
97
|
+
"licence": self.spec.licence,
|
|
98
|
+
"device": self.device,
|
|
99
|
+
"torch": t.__version__,
|
|
100
|
+
"transformers": self.transformers.__version__,
|
|
101
|
+
"max_length": str(self.spec.max_length),
|
|
102
|
+
"score": "log-odds of the attack label",
|
|
103
|
+
"truncation": "longer inputs are cut to max_length tokens (the model card usage)",
|
|
104
|
+
}
|
|
105
|
+
if self.device.startswith("cuda"):
|
|
106
|
+
info["gpu"] = t.cuda.get_device_name(0)
|
|
107
|
+
else:
|
|
108
|
+
info["threads"] = str(t.get_num_threads())
|
|
109
|
+
return info
|
|
110
|
+
|
|
111
|
+
def score_texts(self, texts: Sequence[str]) -> list[float]:
|
|
112
|
+
t = self.torch
|
|
113
|
+
out: list[float] = []
|
|
114
|
+
with t.inference_mode():
|
|
115
|
+
for i in range(0, len(texts), self.batch_size):
|
|
116
|
+
enc = self.tokenizer(
|
|
117
|
+
list(texts[i : i + self.batch_size]),
|
|
118
|
+
truncation=True,
|
|
119
|
+
max_length=self.spec.max_length,
|
|
120
|
+
padding=True,
|
|
121
|
+
return_tensors="pt",
|
|
122
|
+
).to(self.device)
|
|
123
|
+
logits = self.model(
|
|
124
|
+
input_ids=enc["input_ids"], attention_mask=enc["attention_mask"]
|
|
125
|
+
).logits
|
|
126
|
+
# Log-odds of the positive label, not its probability: probabilities round to
|
|
127
|
+
# exactly 1.0 in float32 for confident inputs, and ties can't be thresholded.
|
|
128
|
+
lp = t.log_softmax(logits.double(), dim=-1)
|
|
129
|
+
rest = t.logsumexp(
|
|
130
|
+
t.cat([lp[:, : self.positive], lp[:, self.positive + 1 :]], dim=-1), dim=-1
|
|
131
|
+
)
|
|
132
|
+
out.extend(float(v) for v in (lp[:, self.positive] - rest).cpu().tolist())
|
|
133
|
+
return out
|
mobiusguard/cli.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Command line: ``mobiusguard serve`` runs the proxy; ``mobiusguard check`` tries the guard.
|
|
2
|
+
|
|
3
|
+
mobiusguard serve [--config mobiusguard.toml] [--host 127.0.0.1] [--port 8787]
|
|
4
|
+
mobiusguard check "Ignore all previous instructions"
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .apis import Text
|
|
16
|
+
from .config import load
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
20
|
+
ap = argparse.ArgumentParser(prog="mobiusguard", description=__doc__,
|
|
21
|
+
formatter_class=argparse.RawDescriptionHelpFormatter) # fmt: skip
|
|
22
|
+
sub = ap.add_subparsers(dest="command", required=True)
|
|
23
|
+
serve = sub.add_parser("serve", help="run the proxy")
|
|
24
|
+
serve.add_argument("--config", type=Path)
|
|
25
|
+
serve.add_argument("--host", default="127.0.0.1")
|
|
26
|
+
serve.add_argument("--port", type=int, default=8787)
|
|
27
|
+
chk = sub.add_parser("check", help="run the request check on a text and print the decision")
|
|
28
|
+
chk.add_argument("text", nargs="?", help="text to check (default: read stdin)")
|
|
29
|
+
chk.add_argument("--config", type=Path)
|
|
30
|
+
a = ap.parse_args(argv)
|
|
31
|
+
cfg = load(a.config)
|
|
32
|
+
|
|
33
|
+
if a.command == "check":
|
|
34
|
+
from .guard import Guard
|
|
35
|
+
|
|
36
|
+
text = a.text if a.text is not None else sys.stdin.read()
|
|
37
|
+
d = Guard(cfg).decide([Text("user", text, "input")])
|
|
38
|
+
print(json.dumps({"decision": d.action, "reason": d.reason, "score": d.score,
|
|
39
|
+
"rules": [{"id": h.rule, "severity": h.severity, "view": h.view}
|
|
40
|
+
for h in d.hits]}, indent=2)) # fmt: skip
|
|
41
|
+
return 1 if d.action == "block" else 0
|
|
42
|
+
|
|
43
|
+
from .config import warnings
|
|
44
|
+
|
|
45
|
+
for w in warnings(cfg):
|
|
46
|
+
print(f"mobiusguard: warning: {w}", file=sys.stderr)
|
|
47
|
+
|
|
48
|
+
import uvicorn
|
|
49
|
+
|
|
50
|
+
from .app import create_app
|
|
51
|
+
from .hooks import ProxyHooks
|
|
52
|
+
|
|
53
|
+
uvicorn.run(create_app(cfg, hooks=ProxyHooks(cfg)), host=a.host, port=a.port)
|
|
54
|
+
return 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
raise SystemExit(main())
|