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.
@@ -0,0 +1,6 @@
1
+ """Mobius Guard: an OpenAI-compatible LLM firewall proxy.
2
+
3
+ Streaming redaction and an honest, held-out evaluation.
4
+ """
5
+
6
+ __version__ = "0.1.0"
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())