stackone-defender 0.8.2__tar.gz → 0.9.1__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.
Files changed (70) hide show
  1. stackone_defender-0.9.1/.release-please-manifest.json +1 -0
  2. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/CHANGELOG.md +19 -0
  3. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/PKG-INFO +117 -10
  4. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/README.md +114 -9
  5. stackone_defender-0.9.1/prof.out +0 -0
  6. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/pyproject.toml +4 -1
  7. stackone_defender-0.9.1/scripts/sync_conformance.sh +47 -0
  8. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/__init__.py +24 -0
  9. stackone_defender-0.9.1/src/stackone_defender/classifiers/tier3_llm_provider.py +367 -0
  10. stackone_defender-0.9.1/src/stackone_defender/classifiers/tier3_openai_compatible.py +250 -0
  11. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/core/prompt_defense.py +399 -170
  12. stackone_defender-0.9.1/src/stackone_defender/core/tier3_input.py +585 -0
  13. stackone_defender-0.9.1/src/stackone_defender/core/tier3_policy.py +490 -0
  14. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/types.py +108 -2
  15. stackone_defender-0.9.1/src/stackone_defender/utils/redact.py +99 -0
  16. stackone_defender-0.9.1/src/stackone_defender/utils/text.py +97 -0
  17. stackone_defender-0.9.1/tests/conformance/README.md +82 -0
  18. stackone_defender-0.9.1/tests/conformance/VERSION +1 -0
  19. stackone_defender-0.9.1/tests/conformance/tier3-parse.json +176 -0
  20. stackone_defender-0.9.1/tests/conformance/tier3-policy.json +1742 -0
  21. stackone_defender-0.9.1/tests/conformance/tier3-prompt.json +342 -0
  22. stackone_defender-0.9.1/tests/conformance/tier3-redact.json +242 -0
  23. stackone_defender-0.9.1/tests/conformance/tier3-serialize.json +3794 -0
  24. stackone_defender-0.9.1/tests/conformance_known_failures.txt +5 -0
  25. stackone_defender-0.9.1/tests/test_conformance.py +357 -0
  26. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/tests/test_onnx_classifier.py +3 -4
  27. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/tests/test_tier3.py +287 -14
  28. stackone_defender-0.9.1/tests/test_tier3_policy.py +580 -0
  29. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/uv.lock +9 -3
  30. stackone_defender-0.8.2/.release-please-manifest.json +0 -1
  31. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/.gitignore +0 -0
  32. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/.python-version +0 -0
  33. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/.release-please-config.json +0 -0
  34. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/LICENSE +0 -0
  35. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/classifiers/__init__.py +0 -0
  36. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/classifiers/onnx_classifier.py +0 -0
  37. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/classifiers/pattern_detector.py +0 -0
  38. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/classifiers/patterns.py +0 -0
  39. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/classifiers/tier2_classifier.py +0 -0
  40. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/classifiers/tier3_orchestrator.py +0 -0
  41. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/config.py +0 -0
  42. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/core/__init__.py +0 -0
  43. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/core/sentence_cleaner.py +0 -0
  44. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/core/tool_result_sanitizer.py +0 -0
  45. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/models/minilm-multihead-v5/classifier_config.json +0 -0
  46. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/models/minilm-multihead-v5/config.json +0 -0
  47. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/models/minilm-multihead-v5/model_quantized.onnx +0 -0
  48. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/models/minilm-multihead-v5/tokenizer.json +0 -0
  49. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/models/minilm-multihead-v5/tokenizer_config.json +0 -0
  50. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/py.typed +0 -0
  51. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/sanitizers/__init__.py +0 -0
  52. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/sanitizers/encoding_detector.py +0 -0
  53. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/sanitizers/leet_normalizer.py +0 -0
  54. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/sanitizers/normalizer.py +0 -0
  55. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/sanitizers/role_stripper.py +0 -0
  56. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/sfe/__init__.py +0 -0
  57. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/sfe/model.ftz +0 -0
  58. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/sfe/preprocess.py +0 -0
  59. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/utils/__init__.py +0 -0
  60. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/utils/boundary.py +0 -0
  61. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/utils/field_detection.py +0 -0
  62. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/utils/structure.py +0 -0
  63. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/tests/__init__.py +0 -0
  64. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/tests/test_integration.py +0 -0
  65. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/tests/test_pattern_detector.py +0 -0
  66. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/tests/test_sanitizers.py +0 -0
  67. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/tests/test_sentence_cleaner.py +0 -0
  68. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/tests/test_sfe.py +0 -0
  69. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/tests/test_tier2_classifier.py +0 -0
  70. {stackone_defender-0.8.2 → stackone_defender-0.9.1}/tests/test_utils.py +0 -0
@@ -0,0 +1 @@
1
+ {".":"0.9.1"}
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.1](https://github.com/StackOneHQ/defender-py/compare/stackone-defender-v0.9.0...stackone-defender-v0.9.1) (2026-10-20)
4
+
5
+
6
+ ### Features
7
+
8
+ * **ENG-3500:** bring-your-own-LLM Tier 3 and policy options ([#38](https://github.com/StackOneHQ/defender-py/issues/38)) ([9923491](https://github.com/StackOneHQ/defender-py/commit/9923491c413bafa09d8aee0c47aabd43ed049d09))
9
+
10
+ ## [0.9.0](https://github.com/StackOneHQ/defender-py/compare/stackone-defender-v0.8.2...stackone-defender-v0.9.0) (2026-10-20)
11
+
12
+
13
+ ### Features
14
+
15
+ * **ENG-3500:** record-oriented, chunked Tier-3 review in tier3_only ([#35](https://github.com/StackOneHQ/defender-py/issues/35)) ([072d7ed](https://github.com/StackOneHQ/defender-py/commit/072d7ed7f0474afa5ffe87a66920d279ad7fafdd))
16
+
17
+
18
+ ### Miscellaneous Chores
19
+
20
+ * **ENG-3500:** release 0.9.0 ([#37](https://github.com/StackOneHQ/defender-py/issues/37)) ([aa4a4e3](https://github.com/StackOneHQ/defender-py/commit/aa4a4e39fcb7a7adc6d43fbab8d7b84817613dbc))
21
+
3
22
  ## [0.8.2](https://github.com/StackOneHQ/defender-py/compare/stackone-defender-v0.7.4...stackone-defender-v0.8.2) (2026-08-19)
4
23
 
5
24
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: stackone-defender
3
- Version: 0.8.2
3
+ Version: 0.9.1
4
4
  Summary: Indirect prompt injection defense for AI agents using tool calls
5
5
  Project-URL: Homepage, https://github.com/StackOneHQ/stackone-defender
6
6
  Project-URL: Repository, https://github.com/StackOneHQ/stackone-defender
@@ -16,6 +16,8 @@ Classifier: Programming Language :: Python :: 3.13
16
16
  Classifier: Topic :: Security
17
17
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
18
  Requires-Python: >=3.11
19
+ Provides-Extra: llm
20
+ Requires-Dist: httpx>=0.27; extra == 'llm'
19
21
  Provides-Extra: onnx
20
22
  Requires-Dist: numpy>=1.24.0; extra == 'onnx'
21
23
  Requires-Dist: onnxruntime>=1.16.0; extra == 'onnx'
@@ -83,6 +85,15 @@ pip install stackone-defender[sfe]
83
85
  # or: uv add "stackone-defender[sfe]"
84
86
  ```
85
87
 
88
+ **Tier 3 over an OpenAI-compatible endpoint (optional)** — add extras:
89
+
90
+ ```bash
91
+ pip install stackone-defender[llm]
92
+ # or: uv add "stackone-defender[llm]"
93
+ ```
94
+
95
+ The `[llm]` extra installs [`httpx`](https://www.python-httpx.org/) for `create_openai_compatible_tier3_provider`. `create_llm_tier3_provider` needs no extra.
96
+
86
97
  The `[sfe]` extra installs [`fasttext-ng`](https://pypi.org/project/fasttext-ng/) (provides the `fasttext` module). It requires **NumPy 2.3+**. PyPI may ship a wheel only for some platforms; otherwise pip/uv builds from source (needs a C++ toolchain).
87
98
 
88
99
  ## Quick start
@@ -148,15 +159,58 @@ Packed-chunk MiniLM classifier (int8 ONNX ~22 MB, bundled):
148
159
  | jayavibhav (adversarial) | 0.9717 | ~1k |
149
160
  | **Average** | **0.9079** | ~25k |
150
161
 
151
- ### Optional Tier 3 — LLM adjudication (consumer-supplied)
162
+ ### Optional Tier 3 — LLM adjudication (bring your own LLM)
152
163
 
153
- Authoritative LLM-based classification for the cases Tier 2 finds ambiguous. The package ships **only** the orchestration and the `Tier3Provider` interface — the actual model endpoint (a hosted LLM, OpenAI, an internal inference service) lives in your code, keeping proprietary models and credentials out of the package.
164
+ Authoritative LLM-based classification for the cases Tier 2 finds ambiguous. You bring the model. Defender gives you three ways to plug it in:
165
+
166
+ 1. **`create_openai_compatible_tier3_provider`**: any OpenAI-compatible `/chat/completions` endpoint (OpenAI, vLLM, Ollama, OpenRouter, LiteLLM). Needs the `[llm]` extra.
167
+ 2. **`create_llm_tier3_provider`**: any other LLM. You write one `complete` function that calls the model; defender owns the prompt, the untrusted-text markers, strict reply parsing and refusal handling.
168
+ 3. **A hand-written provider**: an object with `classify(text, *, ctx=None)` that owns everything itself.
154
169
 
155
170
  Two modes, selected via `defender_mode`:
156
171
  - **`"cascade"`** (default): Tier 1 → Tier 2 → Tier 3, with Tier 3 invoked only when the Tier 2 effective score falls in the gray band (default `[0.3, 0.85)`). The Tier 3 verdict overrides Tier 2 on the escalated chunk — a `block` forces a block, an `allow` rescues it. Outside the band defender skips the round trip.
157
172
  - **`"tier3_only"`**: skip Tier 2; the block/allow decision is the Tier 3 verdict alone. Tier 1 still runs detection; the returned `sanitized` payload is the original content.
158
173
 
159
- Register a provider once at startup, then opt in per instance:
174
+ ```python
175
+ import os
176
+
177
+ from stackone_defender import create_openai_compatible_tier3_provider, create_prompt_defense
178
+
179
+ judge = create_openai_compatible_tier3_provider(
180
+ base_url="https://api.openai.com/v1",
181
+ api_key=os.environ["OPENAI_API_KEY"],
182
+ model="gpt-4.1-mini",
183
+ )
184
+ defense = create_prompt_defense(
185
+ block_high_risk=True,
186
+ enable_tier3=True,
187
+ defender_mode="tier3_only",
188
+ tier3={"provider": judge, "timeout_ms": 10_000, "on_error": "block"},
189
+ )
190
+ result = await defense.defend_tool_result_async(
191
+ tool_output, "gmail_get_message", context={"user_goal": "Summarize my unread mail"}
192
+ )
193
+ ```
194
+
195
+ Around any other LLM, `complete` gets a request with `messages` (system + user), `deadline` and `json_schema`, and returns the reply text (sync or async):
196
+
197
+ ```python
198
+ import time
199
+
200
+ from stackone_defender import create_llm_tier3_provider
201
+
202
+ async def complete(req):
203
+ # req.deadline is absolute (time.time() seconds), or None when tier3.timeout_ms isn't set.
204
+ timeout = req.deadline - time.time() if req.deadline else 30
205
+ reply = await my_llm.chat(req.messages, timeout=timeout)
206
+ return reply.text # or {"text": ..., "refused": True} / {"text": ..., "score": p_block}
207
+
208
+ judge = create_llm_tier3_provider(complete=complete)
209
+ ```
210
+
211
+ The default prompt (`DEFAULT_TIER3_PROMPT`, version `TIER3_PROMPT_VERSION`) wraps the tool output, user goal and tool description in random-id markers and asks for `{"decision": "block" | "allow", "reason": "..."}`. Pass `prompt=Tier3PromptTemplate(system=..., user=...)` (the `user` part must contain `{{content}}` once; also `{{toolName}}`, `{{userGoal}}`, `{{toolDescription}}`) or a builder function that gets a `PromptInput`. A refusal, a content-filter hit, or a reply that isn't exactly one JSON verdict **blocks** (`result.tier3.refused`), because attacker text can cause those on purpose. `result.tier3.reason` is model output that can quote the attacker: log it, but never feed it back to an agent.
212
+
213
+ A hand-written provider, registered once at startup:
160
214
 
161
215
  ```python
162
216
  from typing import Any
@@ -166,8 +220,9 @@ from stackone_defender.types import Tier3Verdict
166
220
 
167
221
  class MyProvider:
168
222
  def classify(self, text: str, *, ctx: dict[str, Any] | None = None) -> Tier3Verdict:
169
- # Call your LLM endpoint here (sync or awaitable). ctx["toolName"] is available.
170
- verdict = call_my_llm(text, tool_name=(ctx or {}).get("toolName"))
223
+ # Call your LLM endpoint here (sync or awaitable). ctx has tool_name, mode, chunk,
224
+ # user_goal / tool_description (untrusted), and deadline when timeout_ms is set.
225
+ verdict = call_my_llm(text, tool_name=(ctx or {}).get("tool_name"))
171
226
  return Tier3Verdict(decision="block" if verdict.block else "allow", score=verdict.p_block)
172
227
 
173
228
  set_default_tier3_provider(MyProvider())
@@ -179,15 +234,47 @@ defense = create_prompt_defense(
179
234
  tier3={
180
235
  "provider": MyProvider(), # optional: overrides the registry for this instance
181
236
  "escalation_band": {"lower": 0.3, "upper": 0.85}, # cascade gray band; [lower, upper), defaults shown
182
- "max_text_length": 10000, # caps text passed to the provider
237
+ "max_text_length": 10000, # max chars per provider call
238
+ "max_chunks": 5, # tier3_only: chunks reviewed per tool result
239
+ "on_oversize": "skip", # tier3_only: "skip" | "block" | "scan_anyway"
183
240
  "block_threshold": 0.622, # optional; decide on score, not the model's word
184
241
  },
185
242
  )
186
243
  ```
187
244
 
245
+ **What the reviewer sees (`tier3_only`).** The tool result is serialized as `field: value` lines, one block per record, so a bare value keeps its field name (`status: create` rather than `create`, which reads like an instruction). Nested keys are dotted (`user.name: …`), list items are indexed (`items[0]: …`), and each line of a multi-line value keeps its prefix. A large list of only numbers collapses to `key: [N numbers]`. Keys are reviewed even when the value is a number, boolean, `None` or empty, because keys can carry injected text. Python-only types are reviewed too: mappings and dataclasses as objects, sets and other sequences as lists, UTF-8 `bytes` as text, and anything else as `str(value)`.
246
+
247
+ The serialized text is split into chunks of at most `max_text_length` characters (lengths count UTF-16 code units, as in the TypeScript package). Neighbouring chunks overlap by up to 500 characters, so an injection cut at a chunk boundary still appears whole. All chunks are reviewed in parallel, and any blocking chunk blocks the result. `result.tier3_chunk_summary` reports `chunks`, `blocked` and `blocking_chunk`.
248
+
249
+ **Oversize (`on_oversize`).** Up to about `max_chunks` chunks are reviewed per tool result. Content beyond that is oversize, and `coverage_degraded` is `True`. Blocking below requires `block_high_risk=True`.
250
+ - `"skip"` (default): review the content that fits and allow the rest. A fitting injection still blocks. This favors availability, so set `"block"` or `"scan_anyway"` to fail closed.
251
+ - `"block"`: block without calling the provider. `tier3_chunk_summary` is `None` and `result.tier3.skip_reason` says why.
252
+ - `"scan_anyway"`: review what fits, then block on the unreviewed rest unless a chunk already blocked.
253
+
254
+ In `"skip"` and `"scan_anyway"`, `tier3_chunk_summary.oversize` is `True`.
255
+
256
+ A failed chunk is skipped (fail open), and `result.tier3.errored` and `coverage_degraded` are `True`. With a verdict, `result.tier3.note` says how many chunks failed; it also gives the reason for an `on_oversize` block when the provider allowed. When every chunk fails there's no verdict, and `result.tier3.skip_reason` gives the first failure.
257
+
188
258
  **Choosing the operating point (`block_threshold`).** By default the model's `decision` word is authoritative — but that word is its argmax, an implicit 0.5 cut that moves on its own whenever the model is retrained. Set `tier3.block_threshold` to decide on `verdict.score` (P(block)) instead: the cut becomes an explicit config value — raise it to trade recall for fewer false positives, lower it for the reverse; `0.5` reproduces argmax. It requires a provider that reports `score` as P(block) (not "confidence in whichever decision I made" — those invert on allows). If `score` is missing or outside `[0, 1]` the verdict's `decision` is used instead and defender warns once, so a provider that cannot report a score degrades to the default rather than failing.
189
259
 
190
- **Fail-open semantics.** A provider error or timeout records a `skip_reason` on `result.tier3`; in cascade defender falls back to the Tier 2 decision, in `tier3_only` it allows the request. `enable_tier3=True` with no registered provider falls back to the standard Tier 1 + Tier 2 cascade and logs one warning per instance — Tier 3 misconfiguration never silently disables defense. `result.tier3` carries the verdict (a `Tier3Verdict`, or a `Tier3Skip` when the provider ran but returned nothing usable) when Tier 3 runs, and is `None` when it doesn't.
260
+ **Policy options** (all in `tier3={...}`; camelCase keys work too):
261
+
262
+ | Option | Default | What it does |
263
+ |---|---|---|
264
+ | `allow_override` | `True` | Cascade: whether a Tier-3 `allow` can undo a Tier-2 block. `False`: Tier 3 only adds blocks. `{"max_tier2_score": x}`: only when Tier 2's undamped score is below `x`. A general-purpose LLM can be talked into `allow`, so prefer `False` or a cap (defender warns once otherwise). |
265
+ | `on_error` | cascade `"tier2"`, tier3_only `"allow"` | What a failure (error, timeout, invalid verdict) does. `"tier2"` keeps Tier 2's verdict. `"allow"` treats it as an allow. `"block"` blocks: in cascade when Tier 2 rated the payload medium or higher; in tier3_only when any chunk failed (and `on_oversize` then defaults to `"scan_anyway"`). A missing provider fails closed too. |
266
+ | `decide` | none | `decide(Tier3DecisionInput) -> "block" \| "allow" \| "default"`, called once per call after Tier 3 ran or failed. Replaces `allow_override` and `on_error` for that call. Gets metadata only (never content). A raise or bad return means `"default"`. |
267
+ | `timeout_ms` | none | Per provider call, from when it gets a concurrency slot. An async provider is cancelled at the deadline (`ctx["deadline"]`); a sync one can't be interrupted. |
268
+ | `max_concurrency` | unbounded | Max in-flight provider calls per instance (per event loop). Set `timeout_ms` with it. |
269
+ | `stop_on_first_block` | `False` | tier3_only: cancel the remaining chunk calls once one blocks. |
270
+ | `on_event` | none | Called once per provider call with a `Tier3Event` (kind, mode, chunk, latency, usage, redacted error code). No content. Exceptions are ignored. |
271
+ | `include_raw` | `True` | Keep `raw` on `result.tier3`. `raw` can quote tool content. |
272
+
273
+ **Result fields.** `result.tier3` is a `Tier3Verdict` (or a `Tier3Skip` when no verdict was obtained) and `None` when Tier 3 didn't run. Besides `decision`, `score`, `reason`, `refused` and `prompt_version`, defender sets: `errored` (a real failure), `note` (why the outcome isn't the verdict's alone), `override_applied` / `override_suppressed` (cascade), and `decided_by` (`"policy"` or `"options"` when `decide` is set). Provider error text in `skip_reason`, `note` and events is redacted (keys, tokens, URL paths) and capped.
274
+
275
+ **Cancellation.** Cancelling the task that awaits `defend_tool_result_async` cancels in-flight provider calls and raises `CancelledError`.
276
+
277
+ **Fail-open by default.** Without `on_error="block"`, a provider error or timeout records a `skip_reason`; in cascade defender falls back to the Tier 2 decision, in `tier3_only` it allows. `enable_tier3=True` with no provider (and no `on_error="block"`) falls back to Tier 1 + Tier 2 and logs one warning per instance, so Tier 3 misconfiguration never silently disables defense.
191
278
 
192
279
  ### `allowed` vs `risk_level`
193
280
 
@@ -224,7 +311,7 @@ defense = create_prompt_defense(
224
311
  )
225
312
  ```
226
313
 
227
- ### `defense.defend_tool_result(value, tool_name)`
314
+ ### `defense.defend_tool_result(value, tool_name, *, context=None)`
228
315
 
229
316
  Runs Tier 1 sanitization on risky fields of the **original** payload, then Tier 2 on strings from the SFE-filtered view when SFE is on (otherwise the full value). Optional `tier2_fields` restricts Tier 2 extraction to specific keys; omit it to classify **all** strings (matches `@stackone/defender` 0.6.3). **Synchronous** — no `await`.
230
317
 
@@ -257,7 +344,8 @@ class DefenseResult:
257
344
  cold_load: bool | None = None
258
345
  # Operational signals
259
346
  tier2_available: bool | None = None # False when Tier 2 enabled but failed to load
260
- coverage_degraded: bool | None = None # True when Tier 1 detection coverage was capped
347
+ coverage_degraded: bool | None = None # True when Tier 1 detection or the Tier 3 review didn't cover everything
348
+ tier3_chunk_summary: Tier3ChunkSummary | None = None # tier3_only: chunks, blocked, blocking_chunk, oversize
261
349
  ```
262
350
 
263
351
  ### `defense.defend_tool_results(items)`
@@ -337,6 +425,25 @@ uv sync --group dev
337
425
  uv run pytest
338
426
  ```
339
427
 
428
+ ### Parity with the TypeScript package
429
+
430
+ This package mirrors [`@stackone/defender`](https://github.com/StackOneHQ/defender). `tests/conformance/` is a pinned copy of its shared conformance fixtures: input → expected-output cases for Tier-3 serialization, prompt rendering, verdict parsing, error redaction and policy decisions. `tests/test_conformance.py` runs them against this package.
431
+
432
+ - `scripts/sync_conformance.sh <ref>` copies the fixtures at a TS tag or commit and records it in `tests/conformance/VERSION`. CI checks the copy matches (`--check`).
433
+ - Every case passes. A new TS case this package can't match yet goes in `tests/conformance_known_failures.txt`, where it runs as a strict xfail; remove its line once a port makes it pass.
434
+ - Port contract for the runner: a rejected prompt template and an unusable verdict reply raise `ValueError`; optional result fields are `None` when unset (not `False`).
435
+
436
+ Traps when porting from TypeScript (the fixtures pin TS behavior):
437
+
438
+ - Lengths and slicing are in UTF-16 code units in TS, but `len()` counts code points.
439
+ - TS `Object.entries` puts integer-like keys first. `json.loads` keeps insertion order.
440
+ - Number rendering differs: `String(1.0)` is `"1"` but `str(1.0)` is `"1.0"`. Likewise `-0`, `1e-7` and `1e16` render differently in the two languages.
441
+ - `core/tier3_input.py` handles the three traps above for Tier-3 input. It works on a UTF-16 view of each string, orders keys like `Object.entries`, and renders numbers like `String(n)`. One deliberate difference: Python ints below 1e21 print exactly, where JS would round past 2**53.
442
+ - Python `re` has no `\p{Cf}`, and `unicodedata` versions differ across Python releases.
443
+ - `str.splitlines()` also splits on `\x0b`, `\x0c` and `\x1c`, but TS splits only on `\r`, `\n`, `\u2028`, `\u2029` and `\u0085`.
444
+ - JS `trim()` removes a BOM, but `str.strip()` doesn't.
445
+ - `json.loads` accepts `NaN`, but JS `JSON.parse` rejects it. Use `parse_constant` to reject it.
446
+
340
447
  ## License
341
448
 
342
449
  Apache-2.0 — see [LICENSE](./LICENSE).
@@ -57,6 +57,15 @@ pip install stackone-defender[sfe]
57
57
  # or: uv add "stackone-defender[sfe]"
58
58
  ```
59
59
 
60
+ **Tier 3 over an OpenAI-compatible endpoint (optional)** — add extras:
61
+
62
+ ```bash
63
+ pip install stackone-defender[llm]
64
+ # or: uv add "stackone-defender[llm]"
65
+ ```
66
+
67
+ The `[llm]` extra installs [`httpx`](https://www.python-httpx.org/) for `create_openai_compatible_tier3_provider`. `create_llm_tier3_provider` needs no extra.
68
+
60
69
  The `[sfe]` extra installs [`fasttext-ng`](https://pypi.org/project/fasttext-ng/) (provides the `fasttext` module). It requires **NumPy 2.3+**. PyPI may ship a wheel only for some platforms; otherwise pip/uv builds from source (needs a C++ toolchain).
61
70
 
62
71
  ## Quick start
@@ -122,15 +131,58 @@ Packed-chunk MiniLM classifier (int8 ONNX ~22 MB, bundled):
122
131
  | jayavibhav (adversarial) | 0.9717 | ~1k |
123
132
  | **Average** | **0.9079** | ~25k |
124
133
 
125
- ### Optional Tier 3 — LLM adjudication (consumer-supplied)
134
+ ### Optional Tier 3 — LLM adjudication (bring your own LLM)
126
135
 
127
- Authoritative LLM-based classification for the cases Tier 2 finds ambiguous. The package ships **only** the orchestration and the `Tier3Provider` interface — the actual model endpoint (a hosted LLM, OpenAI, an internal inference service) lives in your code, keeping proprietary models and credentials out of the package.
136
+ Authoritative LLM-based classification for the cases Tier 2 finds ambiguous. You bring the model. Defender gives you three ways to plug it in:
137
+
138
+ 1. **`create_openai_compatible_tier3_provider`**: any OpenAI-compatible `/chat/completions` endpoint (OpenAI, vLLM, Ollama, OpenRouter, LiteLLM). Needs the `[llm]` extra.
139
+ 2. **`create_llm_tier3_provider`**: any other LLM. You write one `complete` function that calls the model; defender owns the prompt, the untrusted-text markers, strict reply parsing and refusal handling.
140
+ 3. **A hand-written provider**: an object with `classify(text, *, ctx=None)` that owns everything itself.
128
141
 
129
142
  Two modes, selected via `defender_mode`:
130
143
  - **`"cascade"`** (default): Tier 1 → Tier 2 → Tier 3, with Tier 3 invoked only when the Tier 2 effective score falls in the gray band (default `[0.3, 0.85)`). The Tier 3 verdict overrides Tier 2 on the escalated chunk — a `block` forces a block, an `allow` rescues it. Outside the band defender skips the round trip.
131
144
  - **`"tier3_only"`**: skip Tier 2; the block/allow decision is the Tier 3 verdict alone. Tier 1 still runs detection; the returned `sanitized` payload is the original content.
132
145
 
133
- Register a provider once at startup, then opt in per instance:
146
+ ```python
147
+ import os
148
+
149
+ from stackone_defender import create_openai_compatible_tier3_provider, create_prompt_defense
150
+
151
+ judge = create_openai_compatible_tier3_provider(
152
+ base_url="https://api.openai.com/v1",
153
+ api_key=os.environ["OPENAI_API_KEY"],
154
+ model="gpt-4.1-mini",
155
+ )
156
+ defense = create_prompt_defense(
157
+ block_high_risk=True,
158
+ enable_tier3=True,
159
+ defender_mode="tier3_only",
160
+ tier3={"provider": judge, "timeout_ms": 10_000, "on_error": "block"},
161
+ )
162
+ result = await defense.defend_tool_result_async(
163
+ tool_output, "gmail_get_message", context={"user_goal": "Summarize my unread mail"}
164
+ )
165
+ ```
166
+
167
+ Around any other LLM, `complete` gets a request with `messages` (system + user), `deadline` and `json_schema`, and returns the reply text (sync or async):
168
+
169
+ ```python
170
+ import time
171
+
172
+ from stackone_defender import create_llm_tier3_provider
173
+
174
+ async def complete(req):
175
+ # req.deadline is absolute (time.time() seconds), or None when tier3.timeout_ms isn't set.
176
+ timeout = req.deadline - time.time() if req.deadline else 30
177
+ reply = await my_llm.chat(req.messages, timeout=timeout)
178
+ return reply.text # or {"text": ..., "refused": True} / {"text": ..., "score": p_block}
179
+
180
+ judge = create_llm_tier3_provider(complete=complete)
181
+ ```
182
+
183
+ The default prompt (`DEFAULT_TIER3_PROMPT`, version `TIER3_PROMPT_VERSION`) wraps the tool output, user goal and tool description in random-id markers and asks for `{"decision": "block" | "allow", "reason": "..."}`. Pass `prompt=Tier3PromptTemplate(system=..., user=...)` (the `user` part must contain `{{content}}` once; also `{{toolName}}`, `{{userGoal}}`, `{{toolDescription}}`) or a builder function that gets a `PromptInput`. A refusal, a content-filter hit, or a reply that isn't exactly one JSON verdict **blocks** (`result.tier3.refused`), because attacker text can cause those on purpose. `result.tier3.reason` is model output that can quote the attacker: log it, but never feed it back to an agent.
184
+
185
+ A hand-written provider, registered once at startup:
134
186
 
135
187
  ```python
136
188
  from typing import Any
@@ -140,8 +192,9 @@ from stackone_defender.types import Tier3Verdict
140
192
 
141
193
  class MyProvider:
142
194
  def classify(self, text: str, *, ctx: dict[str, Any] | None = None) -> Tier3Verdict:
143
- # Call your LLM endpoint here (sync or awaitable). ctx["toolName"] is available.
144
- verdict = call_my_llm(text, tool_name=(ctx or {}).get("toolName"))
195
+ # Call your LLM endpoint here (sync or awaitable). ctx has tool_name, mode, chunk,
196
+ # user_goal / tool_description (untrusted), and deadline when timeout_ms is set.
197
+ verdict = call_my_llm(text, tool_name=(ctx or {}).get("tool_name"))
145
198
  return Tier3Verdict(decision="block" if verdict.block else "allow", score=verdict.p_block)
146
199
 
147
200
  set_default_tier3_provider(MyProvider())
@@ -153,15 +206,47 @@ defense = create_prompt_defense(
153
206
  tier3={
154
207
  "provider": MyProvider(), # optional: overrides the registry for this instance
155
208
  "escalation_band": {"lower": 0.3, "upper": 0.85}, # cascade gray band; [lower, upper), defaults shown
156
- "max_text_length": 10000, # caps text passed to the provider
209
+ "max_text_length": 10000, # max chars per provider call
210
+ "max_chunks": 5, # tier3_only: chunks reviewed per tool result
211
+ "on_oversize": "skip", # tier3_only: "skip" | "block" | "scan_anyway"
157
212
  "block_threshold": 0.622, # optional; decide on score, not the model's word
158
213
  },
159
214
  )
160
215
  ```
161
216
 
217
+ **What the reviewer sees (`tier3_only`).** The tool result is serialized as `field: value` lines, one block per record, so a bare value keeps its field name (`status: create` rather than `create`, which reads like an instruction). Nested keys are dotted (`user.name: …`), list items are indexed (`items[0]: …`), and each line of a multi-line value keeps its prefix. A large list of only numbers collapses to `key: [N numbers]`. Keys are reviewed even when the value is a number, boolean, `None` or empty, because keys can carry injected text. Python-only types are reviewed too: mappings and dataclasses as objects, sets and other sequences as lists, UTF-8 `bytes` as text, and anything else as `str(value)`.
218
+
219
+ The serialized text is split into chunks of at most `max_text_length` characters (lengths count UTF-16 code units, as in the TypeScript package). Neighbouring chunks overlap by up to 500 characters, so an injection cut at a chunk boundary still appears whole. All chunks are reviewed in parallel, and any blocking chunk blocks the result. `result.tier3_chunk_summary` reports `chunks`, `blocked` and `blocking_chunk`.
220
+
221
+ **Oversize (`on_oversize`).** Up to about `max_chunks` chunks are reviewed per tool result. Content beyond that is oversize, and `coverage_degraded` is `True`. Blocking below requires `block_high_risk=True`.
222
+ - `"skip"` (default): review the content that fits and allow the rest. A fitting injection still blocks. This favors availability, so set `"block"` or `"scan_anyway"` to fail closed.
223
+ - `"block"`: block without calling the provider. `tier3_chunk_summary` is `None` and `result.tier3.skip_reason` says why.
224
+ - `"scan_anyway"`: review what fits, then block on the unreviewed rest unless a chunk already blocked.
225
+
226
+ In `"skip"` and `"scan_anyway"`, `tier3_chunk_summary.oversize` is `True`.
227
+
228
+ A failed chunk is skipped (fail open), and `result.tier3.errored` and `coverage_degraded` are `True`. With a verdict, `result.tier3.note` says how many chunks failed; it also gives the reason for an `on_oversize` block when the provider allowed. When every chunk fails there's no verdict, and `result.tier3.skip_reason` gives the first failure.
229
+
162
230
  **Choosing the operating point (`block_threshold`).** By default the model's `decision` word is authoritative — but that word is its argmax, an implicit 0.5 cut that moves on its own whenever the model is retrained. Set `tier3.block_threshold` to decide on `verdict.score` (P(block)) instead: the cut becomes an explicit config value — raise it to trade recall for fewer false positives, lower it for the reverse; `0.5` reproduces argmax. It requires a provider that reports `score` as P(block) (not "confidence in whichever decision I made" — those invert on allows). If `score` is missing or outside `[0, 1]` the verdict's `decision` is used instead and defender warns once, so a provider that cannot report a score degrades to the default rather than failing.
163
231
 
164
- **Fail-open semantics.** A provider error or timeout records a `skip_reason` on `result.tier3`; in cascade defender falls back to the Tier 2 decision, in `tier3_only` it allows the request. `enable_tier3=True` with no registered provider falls back to the standard Tier 1 + Tier 2 cascade and logs one warning per instance — Tier 3 misconfiguration never silently disables defense. `result.tier3` carries the verdict (a `Tier3Verdict`, or a `Tier3Skip` when the provider ran but returned nothing usable) when Tier 3 runs, and is `None` when it doesn't.
232
+ **Policy options** (all in `tier3={...}`; camelCase keys work too):
233
+
234
+ | Option | Default | What it does |
235
+ |---|---|---|
236
+ | `allow_override` | `True` | Cascade: whether a Tier-3 `allow` can undo a Tier-2 block. `False`: Tier 3 only adds blocks. `{"max_tier2_score": x}`: only when Tier 2's undamped score is below `x`. A general-purpose LLM can be talked into `allow`, so prefer `False` or a cap (defender warns once otherwise). |
237
+ | `on_error` | cascade `"tier2"`, tier3_only `"allow"` | What a failure (error, timeout, invalid verdict) does. `"tier2"` keeps Tier 2's verdict. `"allow"` treats it as an allow. `"block"` blocks: in cascade when Tier 2 rated the payload medium or higher; in tier3_only when any chunk failed (and `on_oversize` then defaults to `"scan_anyway"`). A missing provider fails closed too. |
238
+ | `decide` | none | `decide(Tier3DecisionInput) -> "block" \| "allow" \| "default"`, called once per call after Tier 3 ran or failed. Replaces `allow_override` and `on_error` for that call. Gets metadata only (never content). A raise or bad return means `"default"`. |
239
+ | `timeout_ms` | none | Per provider call, from when it gets a concurrency slot. An async provider is cancelled at the deadline (`ctx["deadline"]`); a sync one can't be interrupted. |
240
+ | `max_concurrency` | unbounded | Max in-flight provider calls per instance (per event loop). Set `timeout_ms` with it. |
241
+ | `stop_on_first_block` | `False` | tier3_only: cancel the remaining chunk calls once one blocks. |
242
+ | `on_event` | none | Called once per provider call with a `Tier3Event` (kind, mode, chunk, latency, usage, redacted error code). No content. Exceptions are ignored. |
243
+ | `include_raw` | `True` | Keep `raw` on `result.tier3`. `raw` can quote tool content. |
244
+
245
+ **Result fields.** `result.tier3` is a `Tier3Verdict` (or a `Tier3Skip` when no verdict was obtained) and `None` when Tier 3 didn't run. Besides `decision`, `score`, `reason`, `refused` and `prompt_version`, defender sets: `errored` (a real failure), `note` (why the outcome isn't the verdict's alone), `override_applied` / `override_suppressed` (cascade), and `decided_by` (`"policy"` or `"options"` when `decide` is set). Provider error text in `skip_reason`, `note` and events is redacted (keys, tokens, URL paths) and capped.
246
+
247
+ **Cancellation.** Cancelling the task that awaits `defend_tool_result_async` cancels in-flight provider calls and raises `CancelledError`.
248
+
249
+ **Fail-open by default.** Without `on_error="block"`, a provider error or timeout records a `skip_reason`; in cascade defender falls back to the Tier 2 decision, in `tier3_only` it allows. `enable_tier3=True` with no provider (and no `on_error="block"`) falls back to Tier 1 + Tier 2 and logs one warning per instance, so Tier 3 misconfiguration never silently disables defense.
165
250
 
166
251
  ### `allowed` vs `risk_level`
167
252
 
@@ -198,7 +283,7 @@ defense = create_prompt_defense(
198
283
  )
199
284
  ```
200
285
 
201
- ### `defense.defend_tool_result(value, tool_name)`
286
+ ### `defense.defend_tool_result(value, tool_name, *, context=None)`
202
287
 
203
288
  Runs Tier 1 sanitization on risky fields of the **original** payload, then Tier 2 on strings from the SFE-filtered view when SFE is on (otherwise the full value). Optional `tier2_fields` restricts Tier 2 extraction to specific keys; omit it to classify **all** strings (matches `@stackone/defender` 0.6.3). **Synchronous** — no `await`.
204
289
 
@@ -231,7 +316,8 @@ class DefenseResult:
231
316
  cold_load: bool | None = None
232
317
  # Operational signals
233
318
  tier2_available: bool | None = None # False when Tier 2 enabled but failed to load
234
- coverage_degraded: bool | None = None # True when Tier 1 detection coverage was capped
319
+ coverage_degraded: bool | None = None # True when Tier 1 detection or the Tier 3 review didn't cover everything
320
+ tier3_chunk_summary: Tier3ChunkSummary | None = None # tier3_only: chunks, blocked, blocking_chunk, oversize
235
321
  ```
236
322
 
237
323
  ### `defense.defend_tool_results(items)`
@@ -311,6 +397,25 @@ uv sync --group dev
311
397
  uv run pytest
312
398
  ```
313
399
 
400
+ ### Parity with the TypeScript package
401
+
402
+ This package mirrors [`@stackone/defender`](https://github.com/StackOneHQ/defender). `tests/conformance/` is a pinned copy of its shared conformance fixtures: input → expected-output cases for Tier-3 serialization, prompt rendering, verdict parsing, error redaction and policy decisions. `tests/test_conformance.py` runs them against this package.
403
+
404
+ - `scripts/sync_conformance.sh <ref>` copies the fixtures at a TS tag or commit and records it in `tests/conformance/VERSION`. CI checks the copy matches (`--check`).
405
+ - Every case passes. A new TS case this package can't match yet goes in `tests/conformance_known_failures.txt`, where it runs as a strict xfail; remove its line once a port makes it pass.
406
+ - Port contract for the runner: a rejected prompt template and an unusable verdict reply raise `ValueError`; optional result fields are `None` when unset (not `False`).
407
+
408
+ Traps when porting from TypeScript (the fixtures pin TS behavior):
409
+
410
+ - Lengths and slicing are in UTF-16 code units in TS, but `len()` counts code points.
411
+ - TS `Object.entries` puts integer-like keys first. `json.loads` keeps insertion order.
412
+ - Number rendering differs: `String(1.0)` is `"1"` but `str(1.0)` is `"1.0"`. Likewise `-0`, `1e-7` and `1e16` render differently in the two languages.
413
+ - `core/tier3_input.py` handles the three traps above for Tier-3 input. It works on a UTF-16 view of each string, orders keys like `Object.entries`, and renders numbers like `String(n)`. One deliberate difference: Python ints below 1e21 print exactly, where JS would round past 2**53.
414
+ - Python `re` has no `\p{Cf}`, and `unicodedata` versions differ across Python releases.
415
+ - `str.splitlines()` also splits on `\x0b`, `\x0c` and `\x1c`, but TS splits only on `\r`, `\n`, `\u2028`, `\u2029` and `\u0085`.
416
+ - JS `trim()` removes a BOM, but `str.strip()` doesn't.
417
+ - `json.loads` accepts `NaN`, but JS `JSON.parse` rejects it. Use `parse_constant` to reject it.
418
+
314
419
  ## License
315
420
 
316
421
  Apache-2.0 — see [LICENSE](./LICENSE).
Binary file
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "stackone-defender"
3
- version = "0.8.2"
3
+ version = "0.9.1"
4
4
  description = "Indirect prompt injection defense for AI agents using tool calls"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -28,6 +28,8 @@ onnx = ["onnxruntime>=1.16.0", "tokenizers>=0.15.0", "numpy>=1.24.0"]
28
28
  # fasttext-ng provides the `fasttext` module (maintained bindings; supports 3.13).
29
29
  # Pulls numpy>=2.3; SFE still fail-opens when import/load fails.
30
30
  sfe = ["fasttext-ng>=0.9.3"]
31
+ # create_openai_compatible_tier3_provider (Tier 3 over any OpenAI-compatible endpoint).
32
+ llm = ["httpx>=0.27"]
31
33
 
32
34
  [dependency-groups]
33
35
  dev = [
@@ -38,6 +40,7 @@ dev = [
38
40
  "tokenizers>=0.15.0",
39
41
  "numpy>=1.24.0",
40
42
  "fasttext-ng>=0.9.3",
43
+ "httpx>=0.27",
41
44
  ]
42
45
 
43
46
  [build-system]
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env bash
2
+ # Sync the shared conformance fixtures from the TypeScript package (StackOneHQ/defender).
3
+ #
4
+ # scripts/sync_conformance.sh <ref> copy conformance/ at <ref> (a tag like defender-v0.9.2, or a commit SHA)
5
+ # into tests/conformance/ and record <ref> in tests/conformance/VERSION
6
+ # scripts/sync_conformance.sh --check verify tests/conformance/ matches the ref in VERSION (used in CI)
7
+ set -euo pipefail
8
+
9
+ REPO="StackOneHQ/defender"
10
+ DEST="tests/conformance"
11
+
12
+ # A full commit SHA or a release tag only: a branch would float, and anything else may not be a TS ref.
13
+ valid_ref() {
14
+ [[ "$1" =~ ^[0-9a-f]{40}$ || "$1" =~ ^defender-v[0-9]+\.[0-9]+\.[0-9]+$ ]]
15
+ }
16
+
17
+ fetch() {
18
+ local ref="$1" out="$2"
19
+ valid_ref "$ref" || { echo "invalid ref '${ref}': use a full 40-char commit SHA or a defender-vX.Y.Z tag" >&2; exit 2; }
20
+ # Whole archive (small: LFS models come through as pointers); portable across GNU and BSD tar.
21
+ curl -fsSL --retry 3 --retry-delay 2 --max-time 60 "https://codeload.github.com/${REPO}/tar.gz/${ref}" | tar -xz -C "$out" --strip-components=1
22
+ [ -d "$out/conformance" ] || { echo "no conformance/ folder at ${REPO}@${ref}" >&2; exit 1; }
23
+ }
24
+
25
+ cd "$(dirname "$0")/.."
26
+ tmp="$(mktemp -d)"
27
+ trap 'rm -rf "$tmp"' EXIT
28
+
29
+ if [ "${1:-}" = "--check" ]; then
30
+ ref="$(tr -d '[:space:]' < "$DEST/VERSION")"
31
+ fetch "$ref" "$tmp"
32
+ if diff -r --exclude VERSION "$tmp/conformance" "$DEST"; then
33
+ echo "tests/conformance matches ${REPO}@${ref}"
34
+ else
35
+ echo "tests/conformance differs from ${REPO}@${ref}: re-run scripts/sync_conformance.sh ${ref}" >&2
36
+ exit 1
37
+ fi
38
+ exit 0
39
+ fi
40
+
41
+ ref="${1:?usage: $0 <ref> | --check}"
42
+ fetch "$ref" "$tmp"
43
+ rm -rf "$DEST"
44
+ mkdir -p "$DEST"
45
+ cp -R "$tmp/conformance/." "$DEST/"
46
+ echo "$ref" > "$DEST/VERSION"
47
+ echo "synced ${REPO}@${ref} → ${DEST}"
@@ -12,6 +12,18 @@ Usage:
12
12
  """
13
13
 
14
14
  from .classifiers.onnx_classifier import get_default_model_path
15
+ from .classifiers.tier3_llm_provider import (
16
+ DEFAULT_TIER3_PROMPT,
17
+ TIER3_PROMPT_VERSION,
18
+ VERDICT_JSON_SCHEMA,
19
+ LlmCompletionRequest,
20
+ PromptInput,
21
+ Tier3PromptTemplate,
22
+ build_tier3_messages,
23
+ create_llm_tier3_provider,
24
+ parse_verdict_json,
25
+ )
26
+ from .classifiers.tier3_openai_compatible import create_openai_compatible_tier3_provider
15
27
  from .classifiers.tier3_orchestrator import get_default_tier3_provider, set_default_tier3_provider
16
28
  from .core.prompt_defense import PromptDefense, create_prompt_defense
17
29
  from .sfe.preprocess import (
@@ -28,6 +40,7 @@ from .types import (
28
40
  MultiheadConfig,
29
41
  RiskLevel,
30
42
  Tier1Result,
43
+ Tier3ChunkSummary,
31
44
  Tier3Provider,
32
45
  Tier3TokenUsage,
33
46
  Tier3Verdict,
@@ -35,25 +48,36 @@ from .types import (
35
48
  from .utils.boundary import contains_boundary_patterns, generate_boundary_instructions
36
49
 
37
50
  __all__ = [
51
+ "DEFAULT_TIER3_PROMPT",
52
+ "TIER3_PROMPT_VERSION",
53
+ "VERDICT_JSON_SCHEMA",
38
54
  "DefenderMode",
39
55
  "DefenseResult",
40
56
  "DropDecision",
57
+ "LlmCompletionRequest",
41
58
  "MultiheadConfig",
42
59
  "PromptDefense",
60
+ "PromptInput",
43
61
  "RiskLevel",
44
62
  "SfePredictor",
45
63
  "SfePreprocessResult",
46
64
  "Tier1Result",
65
+ "Tier3ChunkSummary",
66
+ "Tier3PromptTemplate",
47
67
  "Tier3Provider",
48
68
  "Tier3TokenUsage",
49
69
  "Tier3Verdict",
70
+ "build_tier3_messages",
50
71
  "contains_boundary_patterns",
72
+ "create_llm_tier3_provider",
73
+ "create_openai_compatible_tier3_provider",
51
74
  "create_prompt_defense",
52
75
  "generate_boundary_instructions",
53
76
  "get_default_model_path",
54
77
  "get_default_predictor",
55
78
  "get_default_sfe_model_path",
56
79
  "get_default_tier3_provider",
80
+ "parse_verdict_json",
57
81
  "set_default_tier3_provider",
58
82
  "sfe_preprocess",
59
83
  ]