stackone-defender 0.9.0__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 (71) hide show
  1. stackone_defender-0.9.1/.release-please-manifest.json +1 -0
  2. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/CHANGELOG.md +7 -0
  3. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/PKG-INFO +81 -9
  4. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/README.md +78 -8
  5. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/pyproject.toml +4 -1
  6. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/__init__.py +22 -0
  7. stackone_defender-0.9.1/src/stackone_defender/classifiers/tier3_llm_provider.py +367 -0
  8. stackone_defender-0.9.1/src/stackone_defender/classifiers/tier3_openai_compatible.py +250 -0
  9. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/core/prompt_defense.py +245 -155
  10. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/core/tier3_input.py +2 -51
  11. stackone_defender-0.9.1/src/stackone_defender/core/tier3_policy.py +490 -0
  12. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/types.py +90 -4
  13. stackone_defender-0.9.1/src/stackone_defender/utils/redact.py +99 -0
  14. stackone_defender-0.9.1/src/stackone_defender/utils/text.py +97 -0
  15. stackone_defender-0.9.1/tests/conformance_known_failures.txt +5 -0
  16. stackone_defender-0.9.1/tests/test_tier3_policy.py +580 -0
  17. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/uv.lock +8 -2
  18. stackone_defender-0.9.0/.release-please-manifest.json +0 -1
  19. stackone_defender-0.9.0/tests/conformance_known_failures.txt +0 -97
  20. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/.gitignore +0 -0
  21. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/.python-version +0 -0
  22. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/.release-please-config.json +0 -0
  23. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/LICENSE +0 -0
  24. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/prof.out +0 -0
  25. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/scripts/sync_conformance.sh +0 -0
  26. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/classifiers/__init__.py +0 -0
  27. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/classifiers/onnx_classifier.py +0 -0
  28. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/classifiers/pattern_detector.py +0 -0
  29. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/classifiers/patterns.py +0 -0
  30. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/classifiers/tier2_classifier.py +0 -0
  31. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/classifiers/tier3_orchestrator.py +0 -0
  32. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/config.py +0 -0
  33. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/core/__init__.py +0 -0
  34. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/core/sentence_cleaner.py +0 -0
  35. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/core/tool_result_sanitizer.py +0 -0
  36. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/models/minilm-multihead-v5/classifier_config.json +0 -0
  37. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/models/minilm-multihead-v5/config.json +0 -0
  38. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/models/minilm-multihead-v5/model_quantized.onnx +0 -0
  39. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/models/minilm-multihead-v5/tokenizer.json +0 -0
  40. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/models/minilm-multihead-v5/tokenizer_config.json +0 -0
  41. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/py.typed +0 -0
  42. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/sanitizers/__init__.py +0 -0
  43. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/sanitizers/encoding_detector.py +0 -0
  44. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/sanitizers/leet_normalizer.py +0 -0
  45. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/sanitizers/normalizer.py +0 -0
  46. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/sanitizers/role_stripper.py +0 -0
  47. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/sfe/__init__.py +0 -0
  48. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/sfe/model.ftz +0 -0
  49. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/sfe/preprocess.py +0 -0
  50. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/utils/__init__.py +0 -0
  51. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/utils/boundary.py +0 -0
  52. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/utils/field_detection.py +0 -0
  53. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/utils/structure.py +0 -0
  54. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/tests/__init__.py +0 -0
  55. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/tests/conformance/README.md +0 -0
  56. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/tests/conformance/VERSION +0 -0
  57. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/tests/conformance/tier3-parse.json +0 -0
  58. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/tests/conformance/tier3-policy.json +0 -0
  59. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/tests/conformance/tier3-prompt.json +0 -0
  60. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/tests/conformance/tier3-redact.json +0 -0
  61. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/tests/conformance/tier3-serialize.json +0 -0
  62. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/tests/test_conformance.py +0 -0
  63. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/tests/test_integration.py +0 -0
  64. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/tests/test_onnx_classifier.py +0 -0
  65. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/tests/test_pattern_detector.py +0 -0
  66. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/tests/test_sanitizers.py +0 -0
  67. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/tests/test_sentence_cleaner.py +0 -0
  68. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/tests/test_sfe.py +0 -0
  69. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/tests/test_tier2_classifier.py +0 -0
  70. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/tests/test_tier3.py +0 -0
  71. {stackone_defender-0.9.0 → stackone_defender-0.9.1}/tests/test_utils.py +0 -0
@@ -0,0 +1 @@
1
+ {".":"0.9.1"}
@@ -1,5 +1,12 @@
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
+
3
10
  ## [0.9.0](https://github.com/StackOneHQ/defender-py/compare/stackone-defender-v0.8.2...stackone-defender-v0.9.0) (2026-10-20)
4
11
 
5
12
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: stackone-defender
3
- Version: 0.9.0
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)
163
+
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:
152
165
 
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.
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())
@@ -202,7 +257,24 @@ A failed chunk is skipped (fail open), and `result.tier3.errored` and `coverage_
202
257
 
203
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.
204
259
 
205
- **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.
206
278
 
207
279
  ### `allowed` vs `risk_level`
208
280
 
@@ -239,7 +311,7 @@ defense = create_prompt_defense(
239
311
  )
240
312
  ```
241
313
 
242
- ### `defense.defend_tool_result(value, tool_name)`
314
+ ### `defense.defend_tool_result(value, tool_name, *, context=None)`
243
315
 
244
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`.
245
317
 
@@ -358,7 +430,7 @@ uv run pytest
358
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.
359
431
 
360
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`).
361
- - Cases this package doesn't match yet are listed in `tests/conformance_known_failures.txt` and run as strict xfails. When a port makes one pass, remove its line.
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.
362
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`).
363
435
 
364
436
  Traps when porting from TypeScript (the fixtures pin TS behavior):
@@ -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)
135
+
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:
126
137
 
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.
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())
@@ -176,7 +229,24 @@ A failed chunk is skipped (fail open), and `result.tier3.errored` and `coverage_
176
229
 
177
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.
178
231
 
179
- **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.
180
250
 
181
251
  ### `allowed` vs `risk_level`
182
252
 
@@ -213,7 +283,7 @@ defense = create_prompt_defense(
213
283
  )
214
284
  ```
215
285
 
216
- ### `defense.defend_tool_result(value, tool_name)`
286
+ ### `defense.defend_tool_result(value, tool_name, *, context=None)`
217
287
 
218
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`.
219
289
 
@@ -332,7 +402,7 @@ uv run pytest
332
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.
333
403
 
334
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`).
335
- - Cases this package doesn't match yet are listed in `tests/conformance_known_failures.txt` and run as strict xfails. When a port makes one pass, remove its line.
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.
336
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`).
337
407
 
338
408
  Traps when porting from TypeScript (the fixtures pin TS behavior):
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "stackone-defender"
3
- version = "0.9.0"
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]
@@ -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 (
@@ -36,26 +48,36 @@ from .types import (
36
48
  from .utils.boundary import contains_boundary_patterns, generate_boundary_instructions
37
49
 
38
50
  __all__ = [
51
+ "DEFAULT_TIER3_PROMPT",
52
+ "TIER3_PROMPT_VERSION",
53
+ "VERDICT_JSON_SCHEMA",
39
54
  "DefenderMode",
40
55
  "DefenseResult",
41
56
  "DropDecision",
57
+ "LlmCompletionRequest",
42
58
  "MultiheadConfig",
43
59
  "PromptDefense",
60
+ "PromptInput",
44
61
  "RiskLevel",
45
62
  "SfePredictor",
46
63
  "SfePreprocessResult",
47
64
  "Tier1Result",
48
65
  "Tier3ChunkSummary",
66
+ "Tier3PromptTemplate",
49
67
  "Tier3Provider",
50
68
  "Tier3TokenUsage",
51
69
  "Tier3Verdict",
70
+ "build_tier3_messages",
52
71
  "contains_boundary_patterns",
72
+ "create_llm_tier3_provider",
73
+ "create_openai_compatible_tier3_provider",
53
74
  "create_prompt_defense",
54
75
  "generate_boundary_instructions",
55
76
  "get_default_model_path",
56
77
  "get_default_predictor",
57
78
  "get_default_sfe_model_path",
58
79
  "get_default_tier3_provider",
80
+ "parse_verdict_json",
59
81
  "set_default_tier3_provider",
60
82
  "sfe_preprocess",
61
83
  ]