stackone-defender 0.9.0__tar.gz → 0.9.2__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 (75) hide show
  1. stackone_defender-0.9.2/.gitattributes +2 -0
  2. stackone_defender-0.9.2/.release-please-manifest.json +1 -0
  3. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/CHANGELOG.md +19 -0
  4. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/PKG-INFO +144 -47
  5. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/README.md +137 -44
  6. stackone_defender-0.9.2/hatch_build.py +39 -0
  7. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/pyproject.toml +12 -3
  8. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/src/stackone_defender/__init__.py +22 -0
  9. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/src/stackone_defender/classifiers/onnx_classifier.py +3 -0
  10. stackone_defender-0.9.2/src/stackone_defender/classifiers/tier3_llm_provider.py +367 -0
  11. stackone_defender-0.9.2/src/stackone_defender/classifiers/tier3_openai_compatible.py +250 -0
  12. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/src/stackone_defender/core/prompt_defense.py +245 -155
  13. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/src/stackone_defender/core/tier3_input.py +2 -51
  14. stackone_defender-0.9.2/src/stackone_defender/core/tier3_policy.py +490 -0
  15. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/src/stackone_defender/sfe/preprocess.py +2 -0
  16. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/src/stackone_defender/types.py +90 -4
  17. stackone_defender-0.9.2/src/stackone_defender/utils/lfs.py +28 -0
  18. stackone_defender-0.9.2/src/stackone_defender/utils/redact.py +99 -0
  19. stackone_defender-0.9.2/src/stackone_defender/utils/text.py +97 -0
  20. stackone_defender-0.9.2/tests/conformance_known_failures.txt +5 -0
  21. stackone_defender-0.9.2/tests/test_lfs.py +90 -0
  22. stackone_defender-0.9.2/tests/test_tier3_policy.py +580 -0
  23. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/uv.lock +53 -2
  24. stackone_defender-0.9.0/.release-please-manifest.json +0 -1
  25. stackone_defender-0.9.0/tests/conformance_known_failures.txt +0 -97
  26. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/.gitignore +0 -0
  27. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/.python-version +0 -0
  28. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/.release-please-config.json +0 -0
  29. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/LICENSE +0 -0
  30. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/prof.out +0 -0
  31. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/scripts/sync_conformance.sh +0 -0
  32. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/src/stackone_defender/classifiers/__init__.py +0 -0
  33. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/src/stackone_defender/classifiers/pattern_detector.py +0 -0
  34. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/src/stackone_defender/classifiers/patterns.py +0 -0
  35. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/src/stackone_defender/classifiers/tier2_classifier.py +0 -0
  36. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/src/stackone_defender/classifiers/tier3_orchestrator.py +0 -0
  37. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/src/stackone_defender/config.py +0 -0
  38. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/src/stackone_defender/core/__init__.py +0 -0
  39. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/src/stackone_defender/core/sentence_cleaner.py +0 -0
  40. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/src/stackone_defender/core/tool_result_sanitizer.py +0 -0
  41. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/src/stackone_defender/models/minilm-multihead-v5/classifier_config.json +0 -0
  42. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/src/stackone_defender/models/minilm-multihead-v5/config.json +0 -0
  43. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/src/stackone_defender/models/minilm-multihead-v5/model_quantized.onnx +0 -0
  44. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/src/stackone_defender/models/minilm-multihead-v5/tokenizer.json +0 -0
  45. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/src/stackone_defender/models/minilm-multihead-v5/tokenizer_config.json +0 -0
  46. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/src/stackone_defender/py.typed +0 -0
  47. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/src/stackone_defender/sanitizers/__init__.py +0 -0
  48. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/src/stackone_defender/sanitizers/encoding_detector.py +0 -0
  49. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/src/stackone_defender/sanitizers/leet_normalizer.py +0 -0
  50. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/src/stackone_defender/sanitizers/normalizer.py +0 -0
  51. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/src/stackone_defender/sanitizers/role_stripper.py +0 -0
  52. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/src/stackone_defender/sfe/__init__.py +0 -0
  53. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/src/stackone_defender/sfe/model.ftz +0 -0
  54. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/src/stackone_defender/utils/__init__.py +0 -0
  55. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/src/stackone_defender/utils/boundary.py +0 -0
  56. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/src/stackone_defender/utils/field_detection.py +0 -0
  57. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/src/stackone_defender/utils/structure.py +0 -0
  58. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/tests/__init__.py +0 -0
  59. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/tests/conformance/README.md +0 -0
  60. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/tests/conformance/VERSION +0 -0
  61. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/tests/conformance/tier3-parse.json +0 -0
  62. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/tests/conformance/tier3-policy.json +0 -0
  63. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/tests/conformance/tier3-prompt.json +0 -0
  64. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/tests/conformance/tier3-redact.json +0 -0
  65. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/tests/conformance/tier3-serialize.json +0 -0
  66. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/tests/test_conformance.py +0 -0
  67. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/tests/test_integration.py +0 -0
  68. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/tests/test_onnx_classifier.py +0 -0
  69. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/tests/test_pattern_detector.py +0 -0
  70. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/tests/test_sanitizers.py +0 -0
  71. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/tests/test_sentence_cleaner.py +0 -0
  72. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/tests/test_sfe.py +0 -0
  73. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/tests/test_tier2_classifier.py +0 -0
  74. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/tests/test_tier3.py +0 -0
  75. {stackone_defender-0.9.0 → stackone_defender-0.9.2}/tests/test_utils.py +0 -0
@@ -0,0 +1,2 @@
1
+ *.ftz filter=lfs diff=lfs merge=lfs -text
2
+ *.onnx filter=lfs diff=lfs merge=lfs -text
@@ -0,0 +1 @@
1
+ {".":"0.9.2"}
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.2](https://github.com/StackOneHQ/defender-py/compare/stackone-defender-v0.9.1...stackone-defender-v0.9.2) (2026-10-20)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **ENG-3282:** store model binaries in Git LFS, with pointer guards ([#41](https://github.com/StackOneHQ/defender-py/issues/41)) ([95d5d41](https://github.com/StackOneHQ/defender-py/commit/95d5d412222d248b5ba1132b7a51f7a133a6717a))
9
+
10
+
11
+ ### Documentation
12
+
13
+ * **ENG-3500:** refresh the README against the 0.9.1 code ([#42](https://github.com/StackOneHQ/defender-py/issues/42)) ([24ac6e6](https://github.com/StackOneHQ/defender-py/commit/24ac6e6dcbec852156162467b6585d6b7e6646aa))
14
+
15
+ ## [0.9.1](https://github.com/StackOneHQ/defender-py/compare/stackone-defender-v0.9.0...stackone-defender-v0.9.1) (2026-10-20)
16
+
17
+
18
+ ### Features
19
+
20
+ * **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))
21
+
3
22
  ## [0.9.0](https://github.com/StackOneHQ/defender-py/compare/stackone-defender-v0.8.2...stackone-defender-v0.9.0) (2026-10-20)
4
23
 
5
24
 
@@ -1,9 +1,11 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: stackone-defender
3
- Version: 0.9.0
3
+ Version: 0.9.2
4
4
  Summary: Indirect prompt injection defense for AI agents using tool calls
5
- Project-URL: Homepage, https://github.com/StackOneHQ/stackone-defender
6
- Project-URL: Repository, https://github.com/StackOneHQ/stackone-defender
5
+ Project-URL: Homepage, https://defender.stackone.com/?utm_source=pypi&utm_medium=listing&utm_campaign=homepage
6
+ Project-URL: Repository, https://github.com/StackOneHQ/defender-py
7
+ Project-URL: Issues, https://github.com/StackOneHQ/defender-py/issues
8
+ Project-URL: Changelog, https://github.com/StackOneHQ/defender-py/blob/main/CHANGELOG.md
7
9
  Author-email: StackOne <support@stackone.com>
8
10
  License-Expression: Apache-2.0
9
11
  License-File: LICENSE
@@ -16,6 +18,8 @@ Classifier: Programming Language :: Python :: 3.13
16
18
  Classifier: Topic :: Security
17
19
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
20
  Requires-Python: >=3.11
21
+ Provides-Extra: llm
22
+ Requires-Dist: httpx>=0.27; extra == 'llm'
19
23
  Provides-Extra: onnx
20
24
  Requires-Dist: numpy>=1.24.0; extra == 'onnx'
21
25
  Requires-Dist: onnxruntime>=1.16.0; extra == 'onnx'
@@ -26,17 +30,20 @@ Description-Content-Type: text/markdown
26
30
 
27
31
  <div align="center">
28
32
 
29
- <picture>
30
- <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/StackOneHQ/defender/main/assets/banner-dark.svg" />
31
- <img src="https://raw.githubusercontent.com/StackOneHQ/defender/main/assets/banner-light.svg" alt="Defender by StackOne — Indirect prompt injection protection for MCP tool calls" width="800" />
32
- </picture>
33
+ <a href="https://www.stackone.com/platform/prompt-injection-guard/?utm_source=pypi&utm_medium=readme&utm_campaign=banner">
34
+ <picture>
35
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/StackOneHQ/defender/main/assets/banner-dark.svg" />
36
+ <img src="https://raw.githubusercontent.com/StackOneHQ/defender/main/assets/banner-light.svg" alt="Defender by StackOne — Indirect prompt injection protection for MCP tool calls" width="800" />
37
+ </picture>
38
+ </a>
33
39
 
34
40
  <p>
35
41
  <a href="https://pypi.org/project/stackone-defender/"><img src="https://img.shields.io/pypi/v/stackone-defender?style=flat-square&color=047B43&label=pypi" alt="PyPI version" /></a>
36
- <a href="https://github.com/StackOneHQ/stackone-defender/releases"><img src="https://img.shields.io/github/v/release/StackOneHQ/stackone-defender?style=flat-square&color=047B43&label=release" alt="latest GitHub release" /></a>
37
- <a href="https://github.com/StackOneHQ/stackone-defender/stargazers"><img src="https://img.shields.io/github/stars/StackOneHQ/stackone-defender?style=flat-square&color=047B43" alt="GitHub stars" /></a>
42
+ <a href="https://github.com/StackOneHQ/defender-py/releases"><img src="https://img.shields.io/github/v/release/StackOneHQ/defender-py?style=flat-square&color=047B43&label=release" alt="latest GitHub release" /></a>
43
+ <a href="https://github.com/StackOneHQ/defender-py/stargazers"><img src="https://img.shields.io/github/stars/StackOneHQ/defender-py?style=flat-square&color=047B43" alt="GitHub stars" /></a>
38
44
  <a href="./LICENSE"><img src="https://img.shields.io/pypi/l/stackone-defender?style=flat-square&color=047B43" alt="License" /></a>
39
45
  <img src="https://img.shields.io/badge/Python-3.11+-047B43?style=flat-square" alt="Python 3.11+" />
46
+ <a href="https://defender.stackone.com?utm_source=pypi&utm_medium=readme&utm_campaign=badge"><img src="https://img.shields.io/badge/site-defender.stackone.com-047B43?style=flat-square&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iI2ZmZiI+PHBhdGggZD0iTTEyIDFMMyA1djZjMCA1LjI1IDMuOCAxMC4xIDkgMTEgNS4yLS45IDktNS43NSA5LTExVjVsLTktNHoiLz48L3N2Zz4=" alt="Defender site" /></a>
40
47
  </p>
41
48
  <p>
42
49
  <img src="https://img.shields.io/badge/model-22MB-047B43?style=flat-square" alt="Model size: 22MB" />
@@ -49,7 +56,7 @@ Description-Content-Type: text/markdown
49
56
 
50
57
  ---
51
58
 
52
- Indirect prompt injection defense for AI agents using tool calls (MCP, CLI, or direct APIs). Detects and neutralizes attacks hidden in tool results (emails, documents, PRs, etc.) before they reach your LLM.
59
+ Indirect prompt injection defense for AI agents using tool calls (MCP, CLI, or direct APIs). Detects and gates prompt injection attacks hidden in tool results (emails, documents, PRs, etc.) before they reach your LLM.
53
60
 
54
61
  **Python package:** [`stackone-defender`](https://pypi.org/project/stackone-defender/) — aligned with [`@stackone/defender`](https://www.npmjs.com/package/@stackone/defender) on npm.
55
62
 
@@ -58,31 +65,35 @@ Indirect prompt injection defense for AI agents using tool calls (MCP, CLI, or d
58
65
  **pip**
59
66
 
60
67
  ```bash
61
- pip install stackone-defender
68
+ pip install "stackone-defender[onnx]"
62
69
  ```
63
70
 
64
71
  **uv**
65
72
 
66
73
  ```bash
67
- uv add stackone-defender
74
+ uv add "stackone-defender[onnx]"
68
75
  ```
69
76
 
70
- **Tier 2 (ONNX)** — add extras:
77
+ The `[onnx]` extra runs Tier 2 (ML classification). The ONNX model (~22MB) is bundled in the wheel, so nothing is downloaded at runtime.
71
78
 
72
- ```bash
73
- pip install stackone-defender[onnx]
74
- # or: uv add "stackone-defender[onnx]"
75
- ```
76
-
77
- The ONNX model (~22MB) is bundled in the wheel — no extra downloads at runtime.
79
+ Tier 2 is on by default. Without the `[onnx]` extra, defender logs one warning, sets `result.tier2_available = False` (alert on it) and runs Tier 1 only. Pass `require_tier2=True` to raise instead, or `enable_tier2=False` to run Tier 1 only on purpose.
78
80
 
79
81
  **SFE preprocessor (optional)** — add extras:
80
82
 
81
83
  ```bash
82
- pip install stackone-defender[sfe]
84
+ 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
@@ -118,7 +129,7 @@ else:
118
129
 
119
130
  Detects (never rewrites) injection signals and records them as evidence:
120
131
 
121
- - **Unicode normalization** (for matching only) — homoglyph resistance (e.g. Cyrillic `а` → ASCII `a`)
132
+ - **Unicode / leet normalization** (for matching only) — homoglyph resistance (e.g. Cyrillic `а` → ASCII `a`)
122
133
  - **Role markers** — `SYSTEM:`, `ASSISTANT:`, `<system>`, `[INST]`, etc.
123
134
  - **Instruction-override patterns** — phrases like “ignore previous instructions”
124
135
  - **Encoding detection** — decode-then-detect: a decoded payload is escalated only if it trips a real attack pattern
@@ -131,6 +142,8 @@ Packed-chunk MiniLM classifier (int8 ONNX ~22 MB, bundled):
131
142
  - Split text into sentences, pack to model-sized chunks, score chunks in batched ONNX calls
132
143
  - Catches paraphrased or novel injections missed by regex
133
144
  - Uses chunked batch inference to bound memory on large payloads
145
+ - The bundled model is multi-head. Its auxiliary head flags meta-discussion and documentation that quotes injection text. Turn the multi-head rule on with `tier2_config={"multihead": MultiheadConfig(main_threshold=..., aux_threshold=...)}`: a chunk then blocks only when its main score >= `main_threshold` and its aux score < `aux_threshold`. The result reports `tier2_aux_score` and `tier2_multihead_blocked`.
146
+ - The bundled model carries calibrated defaults in its `classifier_config.json` (`high_risk_threshold` 0.64 at temperature 2.41). They override library defaults when the model loads.
134
147
 
135
148
  ### Optional SFE preprocessor
136
149
 
@@ -148,26 +161,73 @@ Packed-chunk MiniLM classifier (int8 ONNX ~22 MB, bundled):
148
161
  | jayavibhav (adversarial) | 0.9717 | ~1k |
149
162
  | **Average** | **0.9079** | ~25k |
150
163
 
151
- ### Optional Tier 3 — LLM adjudication (consumer-supplied)
164
+ ### Optional Tier 3 — LLM adjudication (bring your own LLM)
165
+
166
+ An LLM reviews the cases Tier 2 is unsure about (cascade), or every tool result (`tier3_only`). You bring the model. Defender builds the prompt, reads back a `block`/`allow` verdict and applies your policy. Three ways to plug a model in:
152
167
 
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.
168
+ 1. **`create_openai_compatible_tier3_provider`**: any OpenAI-compatible `/chat/completions` endpoint (OpenAI, vLLM, Ollama, OpenRouter, LiteLLM). Needs the `[llm]` extra.
169
+ 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.
170
+ 3. **A hand-written provider**: an object with `classify(text, *, ctx=None)` that owns everything itself.
154
171
 
155
172
  Two modes, selected via `defender_mode`:
156
173
  - **`"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
- - **`"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.
174
+ - **`"tier3_only"`**: skip Tier 2. Only the Tier 3 verdict decides. Tier 1 still fills `detections` but doesn't block. `sanitized` is the original content.
175
+
176
+ ```python
177
+ import os
178
+
179
+ from stackone_defender import create_openai_compatible_tier3_provider, create_prompt_defense
180
+
181
+ judge = create_openai_compatible_tier3_provider(
182
+ base_url="https://api.openai.com/v1",
183
+ api_key=os.environ["OPENAI_API_KEY"],
184
+ model="gpt-4.1-mini",
185
+ )
186
+ defense = create_prompt_defense(
187
+ block_high_risk=True,
188
+ enable_tier3=True,
189
+ defender_mode="tier3_only",
190
+ tier3={"provider": judge, "timeout_ms": 10_000, "on_error": "block"},
191
+ )
192
+
193
+ async def handle(tool_output):
194
+ return await defense.defend_tool_result_async(
195
+ tool_output, "gmail_get_message", context={"user_goal": "Summarize my unread mail"}
196
+ )
197
+ ```
198
+
199
+ `create_openai_compatible_tier3_provider` options: `headers`, `response_format` (`"json_schema"` by default, or `"json_object"` / `"none"`), `max_tokens` (300, sent as `max_completion_tokens` unless `legacy_max_tokens=True`), `temperature` (not sent unless set), `retries` (1 extra attempt on 429, 5xx and network errors, within the deadline), `prompt`, `parse`, and `client` (an `httpx.AsyncClient` to reuse; recommended in production for connection pooling). `base_url` must use https, except for localhost and `127.x.x.x`; `allow_insecure_http=True` allows plain http anyway. Put credentials in `api_key` or `headers`, not in the URL. A moderation hit (`content_filter`, `invalid_prompt`) counts as a refusal and blocks.
158
200
 
159
- Register a provider once at startup, then opt in per instance:
201
+ For any other LLM, write a `complete(req)` function. `req` has `messages` (system + user), `deadline` and `json_schema`. Return the reply text, sync or async:
202
+
203
+ ```python
204
+ import time
205
+
206
+ from stackone_defender import create_llm_tier3_provider
207
+
208
+ async def complete(req):
209
+ # req.deadline is absolute (time.time() seconds), or None when tier3.timeout_ms isn't set.
210
+ timeout = req.deadline - time.time() if req.deadline else 30
211
+ reply = await my_llm.chat(req.messages, timeout=timeout)
212
+ return reply.text # or {"text": ..., "refused": True} / {"text": ..., "score": p_block}
213
+
214
+ judge = create_llm_tier3_provider(complete=complete)
215
+ ```
216
+
217
+ 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 and may use `{{toolName}}`, `{{userGoal}}`, `{{toolDescription}}`; `system` must not contain placeholders), or a builder function that gets a `PromptInput` and returns the message list. 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.
218
+
219
+ A hand-written provider, registered once at startup:
160
220
 
161
221
  ```python
162
222
  from typing import Any
163
223
 
164
- from stackone_defender import create_prompt_defense, set_default_tier3_provider
165
- from stackone_defender.types import Tier3Verdict
224
+ from stackone_defender import Tier3Verdict, create_prompt_defense, set_default_tier3_provider
166
225
 
167
226
  class MyProvider:
168
227
  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"))
228
+ # Call your LLM endpoint here (sync or awaitable). ctx has tool_name, mode, chunk,
229
+ # user_goal / tool_description (untrusted), and deadline when timeout_ms is set.
230
+ verdict = call_my_llm(text, tool_name=(ctx or {}).get("tool_name"))
171
231
  return Tier3Verdict(decision="block" if verdict.block else "allow", score=verdict.p_block)
172
232
 
173
233
  set_default_tier3_provider(MyProvider())
@@ -187,6 +247,8 @@ defense = create_prompt_defense(
187
247
  )
188
248
  ```
189
249
 
250
+ Defender looks up the registered provider on every call, so registering after construction works. `set_default_tier3_provider(None)` clears it (useful in tests), and `get_default_tier3_provider()` reads it.
251
+
190
252
  **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)`.
191
253
 
192
254
  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`.
@@ -200,15 +262,37 @@ In `"skip"` and `"scan_anyway"`, `tier3_chunk_summary.oversize` is `True`.
200
262
 
201
263
  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.
202
264
 
203
- **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.
265
+ **Choosing the operating point (`block_threshold`).** By default the model's `decision` word decides. That word is an implicit 0.5 cut that moves whenever the model is retrained. Set `tier3.block_threshold` to decide on `verdict.score` (P(block)) instead. Raise it for fewer false positives, lower it for more recall; `0.5` reproduces the word. The provider must report `score` as a real P(block), not "confidence in whichever decision I made". If `score` is missing or outside `[0, 1]`, defender uses `decision` and warns once.
266
+
267
+ **Policy options** (all in `tier3={...}`; camelCase keys work too):
204
268
 
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.
269
+ | Option | Default | What it does |
270
+ |---|---|---|
271
+ | `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 when it's `True` with a provider from `create_llm_tier3_provider` or `create_openai_compatible_tier3_provider`. |
272
+ | `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. `"tier2"` isn't valid in tier3_only: defender warns and uses `"allow"`. |
273
+ | `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). Must be synchronous. A raise or bad return means `"default"`. It can't undo a Tier 1 detection, override `on_oversize`, or block when `block_high_risk=False`. |
274
+ | `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. |
275
+ | `max_concurrency` | unbounded | Max in-flight provider calls per instance, per event loop. Each sync `defend_tool_result` call runs its own loop, so the cap doesn't span threads that use the sync API. Set `timeout_ms` with it. |
276
+ | `stop_on_first_block` | `False` | tier3_only: cancel the remaining chunk calls once one blocks. |
277
+ | `on_event` | none | Called once per provider call with a `Tier3Event` (kind, mode, chunk, latency, usage, redacted error code). No content. Exceptions are ignored. |
278
+ | `include_raw` | `True` | Keep `raw` on `result.tier3`. `raw` can quote tool content. |
279
+
280
+ **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. `Tier3Skip`, `Tier3Event` and `Tier3DecisionInput` are in `stackone_defender.types`.
281
+
282
+ - `allowed` is the final answer. With `allow_override=False`, `result.tier3.decision` can be `"allow"` while `allowed` is `False`.
283
+ - Defender warns once after 20 consecutive provider failures on one instance, and once after 20 consecutive refusals or unusable replies. Use `on_event` for alerting.
284
+ - A call that never finishes gives its `max_concurrency` slot back after 10 × `timeout_ms`. Waiting for a slot is capped at 10 × `timeout_ms` too.
285
+ - Tier 3 sends tool output to the endpoint you configure. `reason` and `raw` can quote it: don't log them where tool content mustn't go, and set `include_raw=False` if needed.
286
+
287
+ **Cancellation.** Cancelling the task that awaits `defend_tool_result_async` cancels in-flight provider calls and raises `CancelledError`.
288
+
289
+ **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
290
 
207
291
  ### `allowed` vs `risk_level`
208
292
 
209
293
  - **`DefenseResult.sanitized`** is a **sentence-level cleaned** copy of the tool result (high-scoring sentences dropped within high-risk fields, optionally `[UD-…]` boundary-wrapped). Cleaning is best-effort (capped by detection) — still gate on `allowed`. Set `sanitize_content=False` for pure detect-and-gate: `sanitized` is then the input verbatim.
210
294
  - Use **`allowed`** for gating when `block_high_risk=True`: `False` means do not pass `sanitized` to the model as-is.
211
- - **`risk_level`** is diagnostic: it starts at `default_risk_level` (default `"low"`) and is **escalated** by Tier 1 / Tier 2 signals — not reduced. Use it for logging, not as the sole block signal unless you implement your own policy.
295
+ - **`risk_level`** is diagnostic: it starts at `default_risk_level` (default `"low"`) and is **escalated** by Tier 1 / Tier 2 signals. When Tier 3 runs, its verdict can lower it (a cascade `allow` override) or set it (`tier3_only`). Use it for logging, not as the sole block signal unless you implement your own policy.
212
296
 
213
297
  | Level | Typical trigger |
214
298
  |-------|------------------|
@@ -228,20 +312,21 @@ defense = create_prompt_defense(
228
312
  block_high_risk=False,
229
313
  default_risk_level="low",
230
314
  annotate_boundary=False, # True: wrap risky strings with [UD-…] tags (npm: annotateBoundary)
315
+ sanitize_content=True, # False: detect-and-gate; sanitized is the input verbatim
231
316
  tier2_fields=["subject", "body", "snippet"], # optional: scope Tier 2 to these JSON keys (default: all strings)
232
- use_sfe=True, # optional: enable semantic field extractor preprocessing
233
- config={
234
- "tier2": {
235
- "high_risk_threshold": 0.8,
236
- "tier2_fields": None, # or list[str]; constructor tier2_fields wins if set
237
- },
238
- },
317
+ tier2_config={"high_risk_threshold": 0.64}, # optional; defaults come from the bundled model's classifier_config.json
318
+ use_sfe=False, # True, or {"threshold": 0.5, "predictor": ...}: SFE preprocessing before Tier 2
319
+ enable_tier3=False,
320
+ defender_mode="cascade", # or "tier3_only"; ignored unless enable_tier3
321
+ tier3=None, # dict of Tier 3 options (see Tier 3 above)
239
322
  )
240
323
  ```
241
324
 
242
- ### `defense.defend_tool_result(value, tool_name)`
325
+ Set Tier 2 thresholds with `tier2_config`. The bundled model's calibrated values override library defaults, so threshold keys under `config["tier2"]` are ignored.
243
326
 
244
- 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`.
327
+ ### `defense.defend_tool_result(value, tool_name, *, context=None)`
328
+
329
+ Runs Tier 1 detection on the original payload, then Tier 2 on its strings (the SFE-filtered view when `use_sfe` is on). `tier2_fields` limits Tier 2 to those keys; omit it to classify all strings. The call is synchronous. With `enable_tier3=True` it runs Tier 3 in its own event loop and raises `RuntimeError` inside a running loop: use `await defense.defend_tool_result_async(...)` there. `context` (`user_goal`, `tool_description`) goes to the Tier 3 provider only.
245
330
 
246
331
  ```python
247
332
  from dataclasses import dataclass, field
@@ -257,7 +342,7 @@ class DefenseResult:
257
342
  detected_field_count: int # count of fields with a Tier-1 detection (keys of patterns_by_field); threat-count signal (fields_sanitized len no longer tracks this)
258
343
  tier2_score: float | None = None
259
344
  tier2_raw_score: float | None = None
260
- tier2_aux_score: float | None = None # multi-head models only
345
+ tier2_aux_score: float | None = None # set only when tier2_config["multihead"] is set
261
346
  tier2_multihead_blocked: bool | None = None
262
347
  tier2_skip_reason: str | None = None
263
348
  max_sentence: str | None = None
@@ -278,7 +363,7 @@ class DefenseResult:
278
363
 
279
364
  ### `defense.defend_tool_results(items)`
280
365
 
281
- Sync batch API. When `enable_tier3=True`, uses one `asyncio.run()` and defends items **concurrently** via `asyncio.gather` (same scheduling model as npm `defendToolResults`; blocking sync providers still run one at a time on the event-loop thread). From async code, prefer `defend_tool_results_async`.
366
+ Sync batch API. Each item is `{"value": ..., "tool_name": ..., "context": {...}}` (`context` optional). Without Tier 3 it runs items one by one. With `enable_tier3=True` it runs them concurrently in one `asyncio.run()` (sync providers still run one at a time) and raises `RuntimeError` inside a running event loop. From async code, use `defend_tool_results_async`.
282
367
 
283
368
  ```python
284
369
  results = defense.defend_tool_results([
@@ -310,7 +395,7 @@ Tier 1 only — useful for debugging pattern hits without full tool-result trave
310
395
 
311
396
  ```python
312
397
  defense = create_prompt_defense()
313
- defense.warmup_tier2() # no-op if enable_tier2=False or ONNX extra missing
398
+ defense.warmup_tier2() # no-op if enable_tier2=False; warns (or raises with require_tier2=True) if the [onnx] extra is missing
314
399
  ```
315
400
 
316
401
  ## Integration example
@@ -333,13 +418,13 @@ sanitized = run_tool_and_defend(gmail_api.get_message(msg_id), "gmail_get_messag
333
418
 
334
419
  ## Risky field detection
335
420
 
336
- Only **string** values under configured “risky” keys are Tier-1-scanned — including strings nested inside arrays/objects under those keys (e.g. `{"name": ["…"]}`). [`RiskyFieldConfig`](https://github.com/StackOneHQ/stackone-defender/blob/main/src/stackone_defender/types.py) provides global names/patterns plus **`tool_overrides`** (wildcard tool names → field list), same idea as the npm package. (Tier 2 scans all extracted strings regardless.)
421
+ Only **string** values under configured “risky” keys are Tier-1-scanned — including strings nested inside arrays/objects under those keys (e.g. `{"name": ["…"]}`). [`RiskyFieldConfig`](https://github.com/StackOneHQ/defender-py/blob/main/src/stackone_defender/types.py) provides global names/patterns plus **`tool_overrides`** (wildcard tool names → field list), same idea as the npm package. Tier 1 also scans object keys. Tier 2 scans all string values whatever the field name (unless `tier2_fields` is set), so an injection in `id` or `url` is still scored.
337
422
 
338
423
  | Tool pattern | Scanned fields |
339
424
  |--------------|----------------|
340
425
  | `gmail_*`, `email_*` | subject, body, snippet, content |
341
426
  | `documents_*` | name, description, content, title |
342
- | `github_*` | name, title, body, description, message |
427
+ | `github_*` | name, title, body, description, message, content |
343
428
  | `hris_*` | name, notes, bio, description |
344
429
  | `ats_*` | name, notes, description, summary |
345
430
  | `crm_*` | name, description, notes, content |
@@ -348,7 +433,10 @@ Otherwise the default list applies: `name`, `description`, `content`, `title`, `
348
433
 
349
434
  ## Development
350
435
 
436
+ The bundled models (`*.onnx`, `*.ftz`) are stored with [Git LFS](https://git-lfs.com). Install it before cloning, or run `git lfs install && git lfs pull` in an existing clone. Otherwise the model files are ~130-byte pointers: the build fails with a "Git LFS pointer" error, and Tier 2 / SFE won't load.
437
+
351
438
  ```bash
439
+ git lfs install && git lfs pull
352
440
  uv sync --group dev
353
441
  uv run pytest
354
442
  ```
@@ -358,7 +446,7 @@ uv run pytest
358
446
  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
447
 
360
448
  - `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.
449
+ - 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
450
  - 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
451
 
364
452
  Traps when porting from TypeScript (the fixtures pin TS behavior):
@@ -372,6 +460,15 @@ Traps when porting from TypeScript (the fixtures pin TS behavior):
372
460
  - JS `trim()` removes a BOM, but `str.strip()` doesn't.
373
461
  - `json.loads` accepts `NaN`, but JS `JSON.parse` rejects it. Use `parse_constant` to reject it.
374
462
 
463
+ ## Defender by StackOne
464
+
465
+ Defender is built and maintained by [StackOne](https://www.stackone.com?utm_source=pypi&utm_medium=readme&utm_campaign=about), the enterprise integration platform for AI agents. We run Defender in production to protect agent tool calls against indirect prompt injection.
466
+
467
+ - **Defender site** — [defender.stackone.com](https://defender.stackone.com?utm_source=pypi&utm_medium=readme&utm_campaign=about)
468
+ - **Overview & benchmarks** — [stackone.com/platform/prompt-injection-guard](https://www.stackone.com/platform/prompt-injection-guard/?utm_source=pypi&utm_medium=readme&utm_campaign=about)
469
+ - **StackOne platform** — [stackone.com](https://www.stackone.com?utm_source=pypi&utm_medium=readme&utm_campaign=about)
470
+ - **TypeScript package** — [`@stackone/defender`](https://www.npmjs.com/package/@stackone/defender) ([StackOneHQ/defender](https://github.com/StackOneHQ/defender))
471
+
375
472
  ## License
376
473
 
377
474
  Apache-2.0 — see [LICENSE](./LICENSE).