flowx-border 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.
Files changed (56) hide show
  1. flowx_border/__init__.py +73 -0
  2. flowx_border/adapters/__init__.py +18 -0
  3. flowx_border/adapters/fastapi.py +207 -0
  4. flowx_border/adapters/langgraph.py +95 -0
  5. flowx_border/adapters/llm_guard_compat.py +296 -0
  6. flowx_border/data/disclosure_phrasings.yaml +267 -0
  7. flowx_border/data/instruction_override_phrasings.yaml +390 -0
  8. flowx_border/data/language_profiles.json +39194 -0
  9. flowx_border/data/postal_codes.yaml +283 -0
  10. flowx_border/data/system_prompt_phrasings.yaml +358 -0
  11. flowx_border/detectors/__init__.py +1 -0
  12. flowx_border/detectors/_national_id_checks.py +126 -0
  13. flowx_border/detectors/banned_terms.py +166 -0
  14. flowx_border/detectors/base.py +78 -0
  15. flowx_border/detectors/catalogue.py +233 -0
  16. flowx_border/detectors/checksummed.py +342 -0
  17. flowx_border/detectors/classifier.py +275 -0
  18. flowx_border/detectors/code_present.py +246 -0
  19. flowx_border/detectors/disclosure.py +243 -0
  20. flowx_border/detectors/encoded_payload.py +431 -0
  21. flowx_border/detectors/entity_shapes.py +288 -0
  22. flowx_border/detectors/groundedness.py +302 -0
  23. flowx_border/detectors/guardrails_hub.py +422 -0
  24. flowx_border/detectors/internal_domains.py +172 -0
  25. flowx_border/detectors/invisible_text.py +244 -0
  26. flowx_border/detectors/json_schema.py +170 -0
  27. flowx_border/detectors/language_id.py +258 -0
  28. flowx_border/detectors/markup_injection.py +171 -0
  29. flowx_border/detectors/multilingual.py +416 -0
  30. flowx_border/detectors/national_id_shapes.py +173 -0
  31. flowx_border/detectors/output_format.py +440 -0
  32. flowx_border/detectors/output_leakage.py +143 -0
  33. flowx_border/detectors/pii.py +800 -0
  34. flowx_border/detectors/postal_code.py +384 -0
  35. flowx_border/detectors/reference.py +357 -0
  36. flowx_border/detectors/repetition.py +163 -0
  37. flowx_border/detectors/secrets.py +417 -0
  38. flowx_border/detectors/sql_injection.py +256 -0
  39. flowx_border/detectors/summary_support.py +176 -0
  40. flowx_border/detectors/system_prompt_leakage.py +314 -0
  41. flowx_border/detectors/token_limit.py +203 -0
  42. flowx_border/detectors/topic_scope.py +285 -0
  43. flowx_border/detectors/url_reachability.py +371 -0
  44. flowx_border/engine.py +252 -0
  45. flowx_border/evidence.py +283 -0
  46. flowx_border/models/__init__.py +1 -0
  47. flowx_border/models/onnx.py +219 -0
  48. flowx_border/models/registry.py +680 -0
  49. flowx_border/policy.py +253 -0
  50. flowx_border/py.typed +0 -0
  51. flowx_border/registry.py +284 -0
  52. flowx_border/types.py +210 -0
  53. flowx_border-0.1.0.dist-info/METADATA +161 -0
  54. flowx_border-0.1.0.dist-info/RECORD +56 -0
  55. flowx_border-0.1.0.dist-info/WHEEL +4 -0
  56. flowx_border-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,73 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """flowx-border: inspects the text crossing into and out of an LLM.
3
+
4
+ The public API is two scan functions and a policy loader, and that is the whole of it.
5
+ Everything else in this package is an implementation detail or an adapter. If a task
6
+ appears to need a third public entry point, stop and ask, see CLAUDE.md.
7
+
8
+ Both scan functions raise NotImplementedError until phase 1 of BUILD_PLAN.md.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import TYPE_CHECKING
14
+
15
+ if TYPE_CHECKING:
16
+ from os import PathLike
17
+
18
+ from flowx_border.detectors.base import Context
19
+ from flowx_border.policy import Policy
20
+ from flowx_border.types import Decision
21
+
22
+ __all__ = ["load_policy", "scan_input", "scan_output"]
23
+
24
+
25
+ def load_policy(path: str | PathLike[str]) -> Policy:
26
+ """Load, validate and resolve a policy document, and compute its hash.
27
+
28
+ Raises PolicyError for anything wrong with the file, including an unknown detector
29
+ id. It never falls back to a default: scanning under a policy the caller did not
30
+ write is worse than refusing to start.
31
+ """
32
+ from flowx_border.policy import load_policy as _load
33
+
34
+ return _load(path)
35
+
36
+
37
+ def _scan(text: str, side: str, policy: Policy, ctx: Context | None) -> Decision:
38
+ """The body both scan functions share.
39
+
40
+ `assert_satisfiable` runs first, and it is the reason this indirection
41
+ exists. With a detector missing, `run_scan` would return `allow` for text
42
+ nobody checked, and the caller would archive an evidence record for a scan
43
+ that enforced nothing. So a policy asking a missing detector to block or
44
+ redact raises here instead. Detectors asked only to flag or log are allowed
45
+ through, because there the gap shows up in the record rather than being
46
+ hidden by it.
47
+
48
+ Measured at 0.004 ms against the shipped default policy, against a 1 ms T0
49
+ budget: cheap enough to run per scan rather than cache and risk going stale.
50
+ """
51
+ from flowx_border.engine import run_scan
52
+ from flowx_border.registry import assert_satisfiable, loaded_detectors
53
+
54
+ assert_satisfiable(policy, side)
55
+ return run_scan(text, side, policy, ctx, loaded_detectors())
56
+
57
+
58
+ def scan_input(text: str, policy: Policy, ctx: Context | None = None) -> Decision:
59
+ """Inspect text on its way to the model.
60
+
61
+ Raises DetectorUnavailableError when the policy expects a detector this
62
+ install does not have to enforce something. It does not silently pass the text.
63
+ """
64
+ return _scan(text, "input", policy, ctx)
65
+
66
+
67
+ def scan_output(text: str, policy: Policy, ctx: Context | None = None) -> Decision:
68
+ """Inspect text on its way back from the model.
69
+
70
+ Raises DetectorUnavailableError when the policy expects a detector this
71
+ install does not have to enforce something. It does not silently pass the text.
72
+ """
73
+ return _scan(text, "output", policy, ctx)
@@ -0,0 +1,18 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Integration adapters.
3
+
4
+ Deliberately not imported by `flowx_border/__init__.py`. Each adapter imports a
5
+ framework
6
+ the library does not depend on, so importing the package must not drag LangGraph or
7
+ FastAPI in. A caller reaches for the one they need:
8
+
9
+ from flowx_border.adapters.fastapi import guard_dependency
10
+ from flowx_border.adapters.langgraph import guard_node
11
+ from flowx_border.adapters.llm_guard_compat import scan_prompt
12
+
13
+ None of them is a third public entry point in the sense CLAUDE.md restricts: each is a
14
+ wrapper over `scan_input` and `scan_output`, holds no logic of its own, and would be
15
+ correct to delete. BUILD_PLAN.md puts the ceiling at roughly 120 lines each, on the
16
+ theory
17
+ that an adapter needing more than that is evidence the core API is wrong.
18
+ """
@@ -0,0 +1,207 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """FastAPI integration, offered as a dependency and as middleware.
3
+
4
+ Both forms exist because they suit different applications, and picking one for you would
5
+ be wrong in half the cases.
6
+
7
+ `guard_dependency` is the one to reach for. It scans a field you name, and because it is
8
+ a
9
+ dependency it composes with everything FastAPI already does: it runs after
10
+ validation, it
11
+ can be applied per route, and the Decision arrives as a normal parameter, so the handler
12
+ can decide what to do with a redaction.
13
+
14
+ `GuardMiddleware` is for retrofitting an application whose handlers you would rather not
15
+ touch. It intercepts a JSON path on the request and on the response. It is the blunter
16
+ tool: middleware sees bytes and has to parse them, it cannot know which routes carry
17
+ user
18
+ text, and it applies to all of them until you narrow `paths`.
19
+
20
+ **A block returns 422 rather than raising.** A refusal is a normal outcome of a guard,
21
+ not
22
+ a server fault, and a 5xx would page somebody. 422 says the request was understood and
23
+ rejected on content, which is what happened. The record id goes in the body so a support
24
+ conversation can start from the audit trail rather than from a screenshot.
25
+
26
+ FastAPI **is** imported at module scope here, and that is deliberate.
27
+ `adapters/__init__.py`
28
+ imports no submodule, so `import flowx_border` never reaches this file and the library
29
+ keeps
30
+ FastAPI out of its dependency set. Importing it lazily inside the factory looked tidier
31
+ and
32
+ was actually broken: with `from __future__ import annotations` every annotation is a
33
+ string, FastAPI resolves a dependency's signature with `get_type_hints`, and a
34
+ `Request` that only
35
+ exists as a local name cannot be resolved. It silently degraded to treating `request` as
36
+ a
37
+ query parameter, so every call returned 422 with `{"loc": ["query", "request"]}`.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import json
43
+ from collections.abc import Callable
44
+ from typing import TYPE_CHECKING, Any
45
+
46
+ from fastapi import HTTPException, Request
47
+ from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
48
+ from starlette.responses import JSONResponse, Response
49
+
50
+ if TYPE_CHECKING:
51
+ from flowx_border.policy import Policy
52
+ from flowx_border.types import Decision
53
+
54
+ # What a blocked request answers with. Not 400: the request was well formed. Not 5xx:
55
+ # the
56
+ # server did not fail. 422 is the status FastAPI already uses for content it will not
57
+ # act
58
+ #: on.
59
+ BLOCKED_STATUS = 422
60
+
61
+
62
+ def _blocked_body(decision: Decision) -> dict[str, Any]:
63
+ """The response body for a refusal.
64
+
65
+ Carries the record id and the finding summary, and deliberately not the text or the
66
+ spans. A 4xx body is the most likely thing to end up in a log aggregator, a browser
67
+ console or a support ticket, so it gets the same no-raw-text treatment as the
68
+ evidence
69
+ record itself.
70
+ """
71
+ return {
72
+ "detail": "blocked by content policy",
73
+ "record_id": decision.evidence.record_id,
74
+ "policy_id": decision.evidence.policy_id,
75
+ "findings": [
76
+ {"detector": f.detector_id, "label": f.label} for f in decision.findings
77
+ ],
78
+ }
79
+
80
+
81
+ def guard_dependency(
82
+ policy: Policy,
83
+ *,
84
+ field: str = "prompt",
85
+ side: str = "input",
86
+ ) -> Callable[..., Any]:
87
+ """A FastAPI dependency that scans one field of the JSON body.
88
+
89
+ Returns the Decision, so the handler receives the possibly-redacted text as
90
+ `decision.text` and can still see what was found. Raises HTTPException(422) on a
91
+ block,
92
+ which FastAPI turns into a normal response.
93
+ """
94
+ from flowx_border import scan_input, scan_output
95
+
96
+ scan = scan_input if side == "input" else scan_output
97
+
98
+ async def dependency(request: Request) -> Decision | None:
99
+ try:
100
+ body = await request.json()
101
+ except (json.JSONDecodeError, ValueError):
102
+ # Not JSON, so there is no named field to scan. Letting it through is
103
+ # correct:
104
+ # this dependency guards a field, and a request without that field is the
105
+ # route's problem to reject, not this one's.
106
+ return None
107
+
108
+ text = body.get(field) if isinstance(body, dict) else None
109
+ if not isinstance(text, str) or not text:
110
+ return None
111
+
112
+ decision = scan(text, policy)
113
+ if decision.verdict == "block":
114
+ raise HTTPException(
115
+ status_code=BLOCKED_STATUS, detail=_blocked_body(decision)
116
+ )
117
+ return decision
118
+
119
+ return dependency
120
+
121
+
122
+ def guard_middleware(
123
+ policy: Policy,
124
+ *,
125
+ request_field: str = "prompt",
126
+ response_field: str = "completion",
127
+ paths: tuple[str, ...] = (),
128
+ ) -> Callable[..., Any]:
129
+ """An ASGI middleware factory scanning a request field and a response field.
130
+
131
+ `paths` narrows which routes are touched, and defaulting it to empty means every
132
+ route,
133
+ which is the behaviour someone reaching for middleware is asking for. Narrow it as
134
+ soon
135
+ as you know which routes carry user text: a health check costs a model pass.
136
+ """
137
+ from flowx_border import scan_input, scan_output
138
+
139
+ class GuardMiddleware(BaseHTTPMiddleware):
140
+ async def dispatch(
141
+ self, request: Request, call_next: RequestResponseEndpoint
142
+ ) -> Response:
143
+ if paths and not any(str(request.url.path).startswith(p) for p in paths):
144
+ return await call_next(request)
145
+
146
+ raw = await request.body()
147
+ if raw:
148
+ try:
149
+ body = json.loads(raw)
150
+ except (json.JSONDecodeError, ValueError):
151
+ body = None
152
+ if isinstance(body, dict) and isinstance(body.get(request_field), str):
153
+ decision = scan_input(body[request_field], policy)
154
+ if decision.verdict == "block":
155
+ return JSONResponse(
156
+ status_code=BLOCKED_STATUS, content=_blocked_body(decision)
157
+ )
158
+
159
+ response = await call_next(request)
160
+
161
+ # The response side needs the assembled body, which means buffering the
162
+ # stream. Stated rather than hidden: this makes a streaming response
163
+ # non-streaming, and it is the main reason to prefer the dependency form
164
+ # for an endpoint that streams tokens.
165
+ #
166
+ # Both response shapes are handled. BaseHTTPMiddleware normally hands back
167
+ # a streaming response, but a middleware further down the stack may have
168
+ # buffered it into a plain one already, and assuming body_iterator exists
169
+ # would fail on exactly that stack.
170
+ streaming = getattr(response, "body_iterator", None)
171
+ if streaming is not None:
172
+ payload = b"".join([chunk async for chunk in streaming])
173
+ else:
174
+ payload = bytes(getattr(response, "body", b""))
175
+ try:
176
+ body = json.loads(payload)
177
+ except (json.JSONDecodeError, ValueError):
178
+ # Not JSON, so there is no field to scan. Passed through byte for byte
179
+ # rather than dropped: middleware that eats a non-JSON response because
180
+ # it
181
+ # could not parse it would break every file download in the application.
182
+ return _passthrough(response, payload)
183
+
184
+ if isinstance(body, dict) and isinstance(body.get(response_field), str):
185
+ decision = scan_output(body[response_field], policy)
186
+ if decision.verdict == "block":
187
+ return JSONResponse(
188
+ status_code=BLOCKED_STATUS, content=_blocked_body(decision)
189
+ )
190
+ body[response_field] = decision.text
191
+ return JSONResponse(status_code=response.status_code, content=body)
192
+
193
+ return _passthrough(response, payload)
194
+
195
+ return GuardMiddleware
196
+
197
+
198
+ def _passthrough(response: Response, payload: bytes) -> Response:
199
+ """Return a buffered response unchanged, preserving status and headers."""
200
+ headers = dict(response.headers)
201
+ headers.pop("content-length", None)
202
+ return Response(
203
+ content=payload,
204
+ status_code=response.status_code,
205
+ headers=headers,
206
+ media_type=response.media_type,
207
+ )
@@ -0,0 +1,95 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """A LangGraph node that scans the message state.
3
+
4
+ `guard_node(policy, side)` returns a callable to drop into a graph. It reads the last
5
+ message, scans it, writes back the possibly-redacted text, and puts the Decision on the
6
+ state under `border` so a later node or a human reviewer can read the evidence record.
7
+
8
+ **A block routes, it does not raise.** An exception inside a graph node becomes a
9
+ traceback
10
+ in someone's request handler and takes the whole turn down. Routing to a named terminal
11
+ node keeps the refusal inside the graph's own control flow, which is where a product
12
+ decides what to say to the user. The node name is configurable because only the caller
13
+ knows what their graph calls that state.
14
+
15
+ LangGraph is not imported. The node is a plain callable over a mapping, which is all
16
+ LangGraph requires, and that keeps the framework out of the dependency set.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import Callable, Mapping
22
+ from typing import TYPE_CHECKING, Any
23
+
24
+ if TYPE_CHECKING:
25
+ from flowx_border.detectors.base import Context
26
+ from flowx_border.policy import Policy
27
+
28
+ #: Where the Decision lands on the state. Namespaced so it cannot collide with a graph's
29
+ #: own keys.
30
+ STATE_KEY = "border"
31
+
32
+
33
+ def guard_node(
34
+ policy: Policy,
35
+ side: str = "input",
36
+ *,
37
+ messages_key: str = "messages",
38
+ blocked_node: str = "blocked",
39
+ ctx: Context | None = None,
40
+ ) -> Callable[[Mapping[str, Any]], dict[str, Any]]:
41
+ """Build a graph node that scans the most recent message.
42
+
43
+ Returns a partial state update, which is what LangGraph expects: the message list
44
+ is
45
+ only rewritten when redaction actually changed the text, so a clean scan adds the
46
+ evidence record and touches nothing else.
47
+ """
48
+ from flowx_border import scan_input, scan_output
49
+
50
+ scan = scan_input if side == "input" else scan_output
51
+
52
+ def node(state: Mapping[str, Any]) -> dict[str, Any]:
53
+ messages = list(state.get(messages_key) or [])
54
+ if not messages:
55
+ return {}
56
+
57
+ last = messages[-1]
58
+ text = (
59
+ last.get("content")
60
+ if isinstance(last, Mapping)
61
+ else getattr(last, "content", "")
62
+ )
63
+ if not isinstance(text, str) or not text:
64
+ return {}
65
+
66
+ decision = scan(text, policy, ctx)
67
+ update: dict[str, Any] = {
68
+ STATE_KEY: {
69
+ "verdict": decision.verdict,
70
+ "findings": [f.model_dump() for f in decision.findings],
71
+ "evidence": decision.evidence.model_dump(),
72
+ "elapsed_ms": decision.elapsed_ms,
73
+ }
74
+ }
75
+
76
+ if decision.verdict == "block":
77
+ # A routing hint rather than an exception. The graph decides what a refusal
78
+ # looks like; this node only says that one is required.
79
+ update["next"] = blocked_node
80
+ return update
81
+
82
+ if decision.text != text:
83
+ replaced = dict(last) if isinstance(last, Mapping) else last
84
+ if isinstance(replaced, dict):
85
+ replaced["content"] = decision.text
86
+ else:
87
+ replaced = {
88
+ "role": getattr(last, "role", "user"),
89
+ "content": decision.text,
90
+ }
91
+ update[messages_key] = [*messages[:-1], replaced]
92
+
93
+ return update
94
+
95
+ return node
@@ -0,0 +1,296 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """A source-compatible shim for the archived llm-guard scanner API.
3
+
4
+ Exists so that migrating is an import change rather than a rewrite. `scan_prompt` and
5
+ `scan_output` keep llm-guard's signatures and its tuple return shape, and scanner names
6
+ map onto detector ids where a real equivalent exists.
7
+
8
+ **A scanner with no equivalent raises.** This is the whole design of the file. A shim
9
+ that accepted `BanCode` and quietly did nothing would leave a caller believing code was
10
+ being blocked, and they would have no way to find out except by being breached. An
11
+ exception on the first call is loud, immediate, and fixable. So the mapping below is
12
+ exhaustive:
13
+ every scanner llm-guard shipped is either mapped or listed as unsupported by name, and
14
+ `docs/migrating-from-llm-guard.md` carries the same table for people who would rather
15
+ read than run.
16
+
17
+ One behavioural difference worth stating, because it cannot be shimmed away. llm-guard
18
+ returns a per-scanner dict of scores and a sanitised string. This library returns a
19
+ Decision carrying an evidence record, and the record is the point of it. The tuple is
20
+ reconstructed for compatibility, and `decision_for` hands back the real object for
21
+ anyone ready to use it.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from collections.abc import Sequence
27
+ from typing import TYPE_CHECKING, Any, Final
28
+
29
+ from flowx_border.detectors.catalogue import CATALOGUE
30
+
31
+ if TYPE_CHECKING:
32
+ from flowx_border.policy import Policy
33
+ from flowx_border.types import Decision
34
+
35
+ #: llm-guard scanner name to detector id. Only where the equivalence is real: a mapping
36
+ # that is approximately right is worse than an absent one, because it reports a check
37
+ # the
38
+ #: caller did not ask for and did not get.
39
+ SUPPORTED: Final[dict[str, str]] = {
40
+ # Input scanners.
41
+ "Anonymize": "pii",
42
+ # Listed as unsupported until 2026-08-12, when code_present landed to close exactly
43
+ # this gap. Both scanners ask the same question and get the same detector: the
44
+ # difference upstream is which side it runs on, which is a policy choice here.
45
+ "BanCode": "code_present",
46
+ # Declined during the port because a token count depends on the tokenizer of the
47
+ # model being called, which this library does not know. It knows it now: the policy
48
+ # names the tokenizer and pins it. Landed 2026-08-12.
49
+ "TokenLimit": "token_limit",
50
+ "Code": "code_present",
51
+ # Listed as unsupported until 2026-08-12, with a note saying no detector reported
52
+ # these characters yet. One had shipped: invisible_text is T0, in CORE, and covers
53
+ # bidirectional controls, tag characters and zero-width characters. A migration
54
+ # table that understates what exists sends people away for a capability they already
55
+ # have.
56
+ "InvisibleText": "invisible_text",
57
+ "PromptInjection": "injection",
58
+ "Secrets": "secrets",
59
+ "Gibberish": "gibberish",
60
+ "Toxicity": "toxicity",
61
+ "BanTopics": "topic_scope",
62
+ "NSFW": "nsfw",
63
+ # Output scanners.
64
+ "Deanonymize": "output_leakage",
65
+ "Sensitive": "output_leakage",
66
+ "NoRefusal": "politeness",
67
+ "Bias": "bias",
68
+ "FactualConsistency": "groundedness",
69
+ "Relevance": "topic_scope",
70
+ # Added 2026-08-11 with the Guardrails Hub port. Each of these was in UNSUPPORTED
71
+ # until the detector it names existed, and the note there said so. See
72
+ # docs/porting-guardrails-validators.md.
73
+ "BanSubstrings": "banned_terms",
74
+ "BanCompetitors": "banned_terms",
75
+ "JSON": "output_format",
76
+ "Regex": "output_format",
77
+ "ReadingTime": "output_format",
78
+ "URLReachability": "url_reachability",
79
+ # Added 2026-08-16 with language_id. Both were in UNSUPPORTED until it existed,
80
+ # and the note there said what was missing. Language asks whether text is in a
81
+ # permitted language and needs the list; LanguageSame compares an answer against
82
+ # the prompt and needs the prompt's language, which only the caller can supply.
83
+ "Language": "language_id",
84
+ "LanguageSame": "language_id",
85
+ }
86
+
87
+ #: Scanners that map onto a detector whose entire input is data the caller has to
88
+ #: supply. llm-guard took that data as constructor arguments, `BanSubstrings(substrings=
89
+ #: [...])`; here it is policy, and this shim cannot invent it.
90
+ #: Passing one of these without a `policy=` raises. That is the same rule the file
91
+ #: already applies to an unmapped scanner, for the same reason: `banned_terms` with no
92
+ #: terms reports `terms_not_configured` and finds nothing, so accepting the call would
93
+ #: hand back a clean-looking result for a check that never ran. The error names the
94
+ #: option to set, so the fix is in the message.
95
+ NEEDS_POLICY: Final[dict[str, str]] = {
96
+ "BanSubstrings": "banned_terms.options.terms, with whole_words: false",
97
+ "BanCompetitors": "banned_terms.options.terms",
98
+ "Regex": "output_format.options.regex",
99
+ "ReadingTime": "output_format.options.max_reading_seconds",
100
+ "JSON": "output_format.options.json: true",
101
+ "Language": "language_id.options.allowed, a list of language codes",
102
+ "LanguageSame": (
103
+ "language_id.options.match_input: true, and ctx.metadata['input_language']"
104
+ ),
105
+ }
106
+
107
+ # Scanners with no equivalent here, and why. Listed rather than omitted so that the
108
+ # error
109
+ #: can say what the gap is instead of only that there is one.
110
+ UNSUPPORTED: Final[dict[str, str]] = {
111
+ "Sentiment": (
112
+ "no sentiment detector. politeness is the nearest, and it is not the same."
113
+ ),
114
+ "MaliciousURLs": (
115
+ "no URL reputation detector. url_reachability asks whether a link answers, "
116
+ "which is a different question from whether it is hostile, and answering the "
117
+ "second needs a reputation feed this library does not ship."
118
+ ),
119
+ }
120
+
121
+
122
+ class UnsupportedScannerError(NotImplementedError):
123
+ """A scanner this shim will not pretend to implement."""
124
+
125
+
126
+ class UnconfiguredScannerError(ValueError):
127
+ """A mapped scanner whose detector needs data only a policy can supply.
128
+
129
+ Separate from UnsupportedScannerError because it is a different problem with a
130
+ different fix: the check exists and is wired, and what is missing is the list. Both
131
+ raise for the same underlying reason, which is that the alternative is a call that
132
+ returns a clean result for a check that never ran.
133
+ """
134
+
135
+
136
+ def _scanner_names(scanners: Sequence[Any] | None) -> list[str]:
137
+ """Scanner names from classes, instances or bare strings.
138
+
139
+ All three shapes are in the wild: llm-guard's own examples construct instances, its
140
+ README lists classes, and configuration files carry strings.
141
+ """
142
+ names = []
143
+ for scanner in scanners or []:
144
+ name = getattr(scanner, "__name__", None) or type(scanner).__name__
145
+ if isinstance(scanner, str):
146
+ name = scanner
147
+ names.append(name)
148
+ return names
149
+
150
+
151
+ def _detector_ids(scanners: Sequence[Any] | None) -> list[str]:
152
+ """Map a caller's scanner list onto detector ids. Raises on anything unsupported."""
153
+ names = _scanner_names(scanners)
154
+
155
+ unsupported = [name for name in names if name not in SUPPORTED]
156
+ if unsupported:
157
+ details = "\n".join(
158
+ f" {name}: {UNSUPPORTED.get(name, 'not a known llm-guard scanner.')}"
159
+ for name in unsupported
160
+ )
161
+ raise UnsupportedScannerError(
162
+ f"these scanners have no equivalent in flowx-border:\n{details}\n"
163
+ "They raise rather than passing, because a security shim that silently "
164
+ "does nothing is worse than one that fails. See "
165
+ "docs/migrating-from-llm-guard.md."
166
+ )
167
+ return [SUPPORTED[name] for name in names]
168
+
169
+
170
+ def _to_tuple(
171
+ decision: Decision, wanted: list[str]
172
+ ) -> tuple[str, dict[str, bool], dict[str, float]]:
173
+ """llm-guard's (sanitised_text, results_valid, results_score) shape.
174
+
175
+ `results_valid` is False for a detector that found something, matching llm-guard's
176
+ sense of valid. Scores are the highest a detector reported, since llm-guard carried
177
+ one number per scanner and this library carries one per finding.
178
+ """
179
+ worst: dict[str, float] = dict.fromkeys(wanted, 0.0)
180
+ for finding in decision.findings:
181
+ if finding.detector_id in worst:
182
+ worst[finding.detector_id] = max(worst[finding.detector_id], finding.score)
183
+ valid = {detector: score == 0.0 for detector, score in worst.items()}
184
+ return decision.text, valid, worst
185
+
186
+
187
+ def scan_prompt(
188
+ prompt: str, scanners: Sequence[Any] | None = None, policy: Policy | None = None
189
+ ) -> tuple[str, dict[str, bool], dict[str, float]]:
190
+ """llm-guard's scan_prompt, backed by scan_input.
191
+
192
+ `policy` is an addition rather than a rename: llm-guard configured behaviour by
193
+ constructing scanners, and here behaviour is policy, which is data. Without one, the
194
+ scanners you pass are enabled at their defaults and everything else is disabled.
195
+ """
196
+ from flowx_border import scan_input
197
+
198
+ _require_policy_for(_scanner_names(scanners), policy)
199
+ wanted = _detector_ids(scanners)
200
+ decision = scan_input(prompt, policy or _policy_for(wanted))
201
+ return _to_tuple(decision, wanted)
202
+
203
+
204
+ def scan_output(
205
+ prompt: str,
206
+ output: str,
207
+ scanners: Sequence[Any] | None = None,
208
+ policy: Policy | None = None,
209
+ ) -> tuple[str, dict[str, bool], dict[str, float]]:
210
+ """llm-guard's scan_output, backed by scan_output.
211
+
212
+ The prompt is not ignored: it becomes `Context.sources`, which is what lets
213
+ output_leakage tell a leak from the assistant repeating back what the user typed.
214
+ llm-guard's Deanonymize and Sensitive both map here, and both are better for it.
215
+ """
216
+ from flowx_border import scan_output as _scan
217
+ from flowx_border.detectors.base import Context
218
+
219
+ _require_policy_for(_scanner_names(scanners), policy)
220
+ wanted = _detector_ids(scanners)
221
+ decision = _scan(output, policy or _policy_for(wanted), Context(sources=(prompt,)))
222
+ return _to_tuple(decision, wanted)
223
+
224
+
225
+ def decision_for(
226
+ text: str,
227
+ side: str,
228
+ scanners: Sequence[Any] | None = None,
229
+ policy: Policy | None = None,
230
+ ) -> Decision:
231
+ """The real Decision, for a caller who has finished migrating.
232
+
233
+ Here so that the tuple above is a stepping stone rather than a ceiling: the evidence
234
+ record is the reason to be here, and llm-guard's shape has nowhere to put it.
235
+ """
236
+ from flowx_border import scan_input
237
+ from flowx_border import scan_output as _scan
238
+
239
+ _require_policy_for(_scanner_names(scanners), policy)
240
+ wanted = _detector_ids(scanners)
241
+ resolved = policy or _policy_for(wanted)
242
+ return scan_input(text, resolved) if side == "input" else _scan(text, resolved)
243
+
244
+
245
+ def _require_policy_for(names: list[str], policy: Policy | None) -> None:
246
+ """Refuse a scanner that needs configuration when no policy carries it.
247
+
248
+ llm-guard configured a scan by how you constructed the scanner:
249
+ `BanSubstrings(substrings=[...])`. Here configuration is policy, which is data, and
250
+ this shim has nowhere to read a constructor argument from even when the caller
251
+ passes an instance, because the attribute names are private to that library and
252
+ guessing them wrong produces an empty list rather than an error.
253
+
254
+ An empty list is the case that matters. `banned_terms` with no terms reports
255
+ `terms_not_configured` and finds nothing, so accepting the call would return a
256
+ clean-looking tuple for a check that never ran. That is the exact failure this
257
+ shim's unsupported table exists to prevent, so it gets the same treatment.
258
+ """
259
+ if policy is not None:
260
+ return
261
+ unconfigured = [name for name in names if name in NEEDS_POLICY]
262
+ if not unconfigured:
263
+ return
264
+ details = "\n".join(f" {name}: set {NEEDS_POLICY[name]}" for name in unconfigured)
265
+ raise UnconfiguredScannerError(
266
+ f"these scanners need configuration that only a policy can carry:\n{details}\n"
267
+ "llm-guard took it as constructor arguments; here it is policy, because a "
268
+ "policy is data a reviewer can read and its hash pins what ran. Pass policy= "
269
+ "with those options set. This raises rather than scanning, because the "
270
+ "detector would report that it was unconfigured and the tuple would look "
271
+ "clean. See docs/migrating-from-llm-guard.md."
272
+ )
273
+
274
+
275
+ def _policy_for(detector_ids: list[str]) -> Policy:
276
+ """A policy enabling exactly the requested detectors, everything else off.
277
+
278
+ T0 cannot be disabled, so `secrets` and `disclosure` are present whether or not the
279
+ caller asked. They report rather than enforce here, because a migration should not
280
+ start blocking traffic that llm-guard was letting through.
281
+ """
282
+ from flowx_border.policy import DetectorPolicy, Policy
283
+
284
+ wanted = set(detector_ids)
285
+ return Policy(
286
+ policy_id="llm-guard-compat",
287
+ version=1,
288
+ fail_mode=dict.fromkeys(("T0", "T1", "T2", "T3"), "open"),
289
+ detectors={
290
+ detector: DetectorPolicy(
291
+ enabled=detector in wanted or spec.tier == "T0",
292
+ on_fail="flag",
293
+ )
294
+ for detector, spec in CATALOGUE.items()
295
+ },
296
+ )