stackone-defender 0.9.1__tar.gz → 0.9.3__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 (76) hide show
  1. stackone_defender-0.9.3/.gitattributes +2 -0
  2. stackone_defender-0.9.3/.release-please-manifest.json +1 -0
  3. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/CHANGELOG.md +19 -0
  4. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/PKG-INFO +87 -59
  5. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/README.md +82 -56
  6. stackone_defender-0.9.3/hatch_build.py +39 -0
  7. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/pyproject.toml +9 -3
  8. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/src/stackone_defender/classifiers/onnx_classifier.py +3 -0
  9. stackone_defender-0.9.3/src/stackone_defender/models/minilm-multihead-v5/classifier_config.json +75 -0
  10. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/src/stackone_defender/models/minilm-multihead-v5/model_quantized.onnx +0 -0
  11. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/src/stackone_defender/sfe/preprocess.py +2 -0
  12. stackone_defender-0.9.3/src/stackone_defender/utils/lfs.py +28 -0
  13. stackone_defender-0.9.3/tests/conformance/VERSION +1 -0
  14. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/tests/conformance/tier3-policy.json +31 -0
  15. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/tests/conformance/tier3-redact.json +50 -0
  16. stackone_defender-0.9.3/tests/test_lfs.py +90 -0
  17. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/tests/test_onnx_classifier.py +4 -2
  18. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/tests/test_tier2_classifier.py +7 -6
  19. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/uv.lock +52 -7
  20. stackone_defender-0.9.1/.release-please-manifest.json +0 -1
  21. stackone_defender-0.9.1/src/stackone_defender/models/minilm-multihead-v5/classifier_config.json +0 -47
  22. stackone_defender-0.9.1/tests/conformance/VERSION +0 -1
  23. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/.gitignore +0 -0
  24. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/.python-version +0 -0
  25. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/.release-please-config.json +0 -0
  26. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/LICENSE +0 -0
  27. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/prof.out +0 -0
  28. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/scripts/sync_conformance.sh +0 -0
  29. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/src/stackone_defender/__init__.py +0 -0
  30. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/src/stackone_defender/classifiers/__init__.py +0 -0
  31. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/src/stackone_defender/classifiers/pattern_detector.py +0 -0
  32. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/src/stackone_defender/classifiers/patterns.py +0 -0
  33. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/src/stackone_defender/classifiers/tier2_classifier.py +0 -0
  34. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/src/stackone_defender/classifiers/tier3_llm_provider.py +0 -0
  35. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/src/stackone_defender/classifiers/tier3_openai_compatible.py +0 -0
  36. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/src/stackone_defender/classifiers/tier3_orchestrator.py +0 -0
  37. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/src/stackone_defender/config.py +0 -0
  38. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/src/stackone_defender/core/__init__.py +0 -0
  39. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/src/stackone_defender/core/prompt_defense.py +0 -0
  40. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/src/stackone_defender/core/sentence_cleaner.py +0 -0
  41. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/src/stackone_defender/core/tier3_input.py +0 -0
  42. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/src/stackone_defender/core/tier3_policy.py +0 -0
  43. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/src/stackone_defender/core/tool_result_sanitizer.py +0 -0
  44. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/src/stackone_defender/models/minilm-multihead-v5/config.json +0 -0
  45. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/src/stackone_defender/models/minilm-multihead-v5/tokenizer.json +0 -0
  46. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/src/stackone_defender/models/minilm-multihead-v5/tokenizer_config.json +0 -0
  47. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/src/stackone_defender/py.typed +0 -0
  48. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/src/stackone_defender/sanitizers/__init__.py +0 -0
  49. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/src/stackone_defender/sanitizers/encoding_detector.py +0 -0
  50. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/src/stackone_defender/sanitizers/leet_normalizer.py +0 -0
  51. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/src/stackone_defender/sanitizers/normalizer.py +0 -0
  52. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/src/stackone_defender/sanitizers/role_stripper.py +0 -0
  53. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/src/stackone_defender/sfe/__init__.py +0 -0
  54. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/src/stackone_defender/sfe/model.ftz +0 -0
  55. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/src/stackone_defender/types.py +0 -0
  56. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/src/stackone_defender/utils/__init__.py +0 -0
  57. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/src/stackone_defender/utils/boundary.py +0 -0
  58. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/src/stackone_defender/utils/field_detection.py +0 -0
  59. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/src/stackone_defender/utils/redact.py +0 -0
  60. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/src/stackone_defender/utils/structure.py +0 -0
  61. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/src/stackone_defender/utils/text.py +0 -0
  62. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/tests/__init__.py +0 -0
  63. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/tests/conformance/README.md +0 -0
  64. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/tests/conformance/tier3-parse.json +0 -0
  65. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/tests/conformance/tier3-prompt.json +0 -0
  66. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/tests/conformance/tier3-serialize.json +0 -0
  67. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/tests/conformance_known_failures.txt +0 -0
  68. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/tests/test_conformance.py +0 -0
  69. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/tests/test_integration.py +0 -0
  70. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/tests/test_pattern_detector.py +0 -0
  71. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/tests/test_sanitizers.py +0 -0
  72. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/tests/test_sentence_cleaner.py +0 -0
  73. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/tests/test_sfe.py +0 -0
  74. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/tests/test_tier3.py +0 -0
  75. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/tests/test_tier3_policy.py +0 -0
  76. {stackone_defender-0.9.1 → stackone_defender-0.9.3}/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.3"}
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.3](https://github.com/StackOneHQ/defender-py/compare/stackone-defender-v0.9.2...stackone-defender-v0.9.3) (2026-10-20)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **ENG-3282:** swap the Tier-2 model and recalibrate its threshold ([#44](https://github.com/StackOneHQ/defender-py/issues/44)) ([5fd8f87](https://github.com/StackOneHQ/defender-py/commit/5fd8f8748bcc72a42324b5fba09d90465b062135))
9
+
10
+ ## [0.9.2](https://github.com/StackOneHQ/defender-py/compare/stackone-defender-v0.9.1...stackone-defender-v0.9.2) (2026-10-20)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **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))
16
+
17
+
18
+ ### Documentation
19
+
20
+ * **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))
21
+
3
22
  ## [0.9.1](https://github.com/StackOneHQ/defender-py/compare/stackone-defender-v0.9.0...stackone-defender-v0.9.1) (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.1
3
+ Version: 0.9.3
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
@@ -28,30 +30,33 @@ Description-Content-Type: text/markdown
28
30
 
29
31
  <div align="center">
30
32
 
31
- <picture>
32
- <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/StackOneHQ/defender/main/assets/banner-dark.svg" />
33
- <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" />
34
- </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>
35
39
 
36
40
  <p>
37
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>
38
- <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>
39
- <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>
40
44
  <a href="./LICENSE"><img src="https://img.shields.io/pypi/l/stackone-defender?style=flat-square&color=047B43" alt="License" /></a>
41
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>
42
47
  </p>
43
48
  <p>
44
49
  <img src="https://img.shields.io/badge/model-22MB-047B43?style=flat-square" alt="Model size: 22MB" />
45
50
  <img src="https://img.shields.io/badge/latency-~10ms-047B43?style=flat-square" alt="Latency: ~10ms" />
46
51
  <img src="https://img.shields.io/badge/CPU--only-no%20GPU%20needed-047B43?style=flat-square" alt="CPU only" />
47
- <img src="https://img.shields.io/badge/F1%20Score-90.8%25-047B43?style=flat-square" alt="F1 Score: 90.8%" />
52
+ <img src="https://img.shields.io/badge/F1%20Score-86.5%25-047B43?style=flat-square" alt="F1 Score: 86.5%" />
48
53
  </p>
49
54
 
50
55
  </div>
51
56
 
52
57
  ---
53
58
 
54
- 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.
55
60
 
56
61
  **Python package:** [`stackone-defender`](https://pypi.org/project/stackone-defender/) — aligned with [`@stackone/defender`](https://www.npmjs.com/package/@stackone/defender) on npm.
57
62
 
@@ -60,35 +65,30 @@ Indirect prompt injection defense for AI agents using tool calls (MCP, CLI, or d
60
65
  **pip**
61
66
 
62
67
  ```bash
63
- pip install stackone-defender
68
+ pip install "stackone-defender[onnx]"
64
69
  ```
65
70
 
66
71
  **uv**
67
72
 
68
73
  ```bash
69
- uv add stackone-defender
74
+ uv add "stackone-defender[onnx]"
70
75
  ```
71
76
 
72
- **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.
73
78
 
74
- ```bash
75
- pip install stackone-defender[onnx]
76
- # or: uv add "stackone-defender[onnx]"
77
- ```
78
-
79
- 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.
80
80
 
81
81
  **SFE preprocessor (optional)** — add extras:
82
82
 
83
83
  ```bash
84
- pip install stackone-defender[sfe]
84
+ pip install "stackone-defender[sfe]"
85
85
  # or: uv add "stackone-defender[sfe]"
86
86
  ```
87
87
 
88
88
  **Tier 3 over an OpenAI-compatible endpoint (optional)** — add extras:
89
89
 
90
90
  ```bash
91
- pip install stackone-defender[llm]
91
+ pip install "stackone-defender[llm]"
92
92
  # or: uv add "stackone-defender[llm]"
93
93
  ```
94
94
 
@@ -129,7 +129,7 @@ else:
129
129
 
130
130
  Detects (never rewrites) injection signals and records them as evidence:
131
131
 
132
- - **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`)
133
133
  - **Role markers** — `SYSTEM:`, `ASSISTANT:`, `<system>`, `[INST]`, etc.
134
134
  - **Instruction-override patterns** — phrases like “ignore previous instructions”
135
135
  - **Encoding detection** — decode-then-detect: a decoded payload is escalated only if it trips a real attack pattern
@@ -142,6 +142,9 @@ Packed-chunk MiniLM classifier (int8 ONNX ~22 MB, bundled):
142
142
  - Split text into sentences, pack to model-sized chunks, score chunks in batched ONNX calls
143
143
  - Catches paraphrased or novel injections missed by regex
144
144
  - Uses chunked batch inference to bound memory on large payloads
145
+ - The bundled model is multi-head (variant `jbv2-mh-grid-agpjb2pinstr-s2` since 0.9.3; it still lives in the `minilm-multihead-v5` folder so existing paths keep working). Its auxiliary head flags meta-discussion and documentation that quotes injection text.
146
+ - The bundled model carries calibrated defaults in its `classifier_config.json` (`high_risk_threshold` 0.52 at temperature 2.41). They override library defaults when the model loads. Thresholds are model-specific: 0.64 was right for the previous model, not this one.
147
+ - Multi-head mode is off by default; Tier 2 then rates a chunk high at `high_risk_threshold` (blocking also needs `block_high_risk=True`). To turn it on, pass a preset as `tier2_config={"multihead": MultiheadConfig(main_threshold=..., aux_threshold=...)}`: a chunk then blocks when its main score >= `main_threshold` and its aux score < `aux_threshold`, and the result reports `tier2_aux_score` and `tier2_multihead_blocked`. Presets tuned for this model: High 0.77/0.325, Medium 0.88/0.325, Low 0.94/0.30. They're also in `classifier_config.json` under `multihead_presets`, but defender doesn't load them for you.
145
148
 
146
149
  ### Optional SFE preprocessor
147
150
 
@@ -150,18 +153,20 @@ Packed-chunk MiniLM classifier (int8 ONNX ~22 MB, bundled):
150
153
  - **Tier 2** extracts strings from the SFE-filtered tree; `fields_dropped` lists paths omitted from that extraction (not removed from `sanitized`)
151
154
  - Fails open if the runtime/model is unavailable: payload continues unfiltered
152
155
 
153
- **Benchmarks** (F1 @ threshold 0.5):
156
+ **Benchmarks** (Tier 2 only, through the package pipeline at the default threshold 0.52; measured 2026-10-20):
157
+
158
+ | Benchmark | F1 | Precision | Recall | Samples |
159
+ |-----------|-----|-----------|--------|---------|
160
+ | Qualifire (held-out test split; the dataset's train split is used in training) | 0.828 | 0.857 | 0.800 | 751 |
161
+ | xxz224 (out-of-distribution) | 0.814 | 0.989 | 0.691 | ~22.5k |
162
+ | jayavibhav (unseen rows; other rows of this split are used in training) | 0.955 | 0.960 | 0.950 | ~60k |
163
+ | **Average** | **0.865** | | | |
154
164
 
155
- | Benchmark | F1 | Samples |
156
- |-----------|-----|--------|
157
- | Qualifire (in-distribution) | 0.8686 | ~1.5k |
158
- | xxz224 (out-of-distribution) | 0.8834 | ~22.5k |
159
- | jayavibhav (adversarial) | 0.9717 | ~1k |
160
- | **Average** | **0.9079** | ~25k |
165
+ The previous model scores 0.843 on the same sets and method. Earlier READMEs showed 0.908 from a different method (raw scores, best threshold picked per dataset), which isn't comparable. Latency is about 10 ms per call (p50 on a 2-vCPU container, short inputs); long inputs that split into several chunks take longer.
161
166
 
162
167
  ### Optional Tier 3 — LLM adjudication (bring your own LLM)
163
168
 
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:
169
+ 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:
165
170
 
166
171
  1. **`create_openai_compatible_tier3_provider`**: any OpenAI-compatible `/chat/completions` endpoint (OpenAI, vLLM, Ollama, OpenRouter, LiteLLM). Needs the `[llm]` extra.
167
172
  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.
@@ -169,7 +174,7 @@ Authoritative LLM-based classification for the cases Tier 2 finds ambiguous. You
169
174
 
170
175
  Two modes, selected via `defender_mode`:
171
176
  - **`"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.
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.
177
+ - **`"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.
173
178
 
174
179
  ```python
175
180
  import os
@@ -187,12 +192,16 @@ defense = create_prompt_defense(
187
192
  defender_mode="tier3_only",
188
193
  tier3={"provider": judge, "timeout_ms": 10_000, "on_error": "block"},
189
194
  )
190
- result = await defense.defend_tool_result_async(
191
- tool_output, "gmail_get_message", context={"user_goal": "Summarize my unread mail"}
192
- )
195
+
196
+ async def handle(tool_output):
197
+ return await defense.defend_tool_result_async(
198
+ tool_output, "gmail_get_message", context={"user_goal": "Summarize my unread mail"}
199
+ )
193
200
  ```
194
201
 
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):
202
+ `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.
203
+
204
+ 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:
196
205
 
197
206
  ```python
198
207
  import time
@@ -208,15 +217,14 @@ async def complete(req):
208
217
  judge = create_llm_tier3_provider(complete=complete)
209
218
  ```
210
219
 
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.
220
+ 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.
212
221
 
213
222
  A hand-written provider, registered once at startup:
214
223
 
215
224
  ```python
216
225
  from typing import Any
217
226
 
218
- from stackone_defender import create_prompt_defense, set_default_tier3_provider
219
- from stackone_defender.types import Tier3Verdict
227
+ from stackone_defender import Tier3Verdict, create_prompt_defense, set_default_tier3_provider
220
228
 
221
229
  class MyProvider:
222
230
  def classify(self, text: str, *, ctx: dict[str, Any] | None = None) -> Tier3Verdict:
@@ -242,6 +250,8 @@ defense = create_prompt_defense(
242
250
  )
243
251
  ```
244
252
 
253
+ 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.
254
+
245
255
  **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
256
 
247
257
  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`.
@@ -255,22 +265,27 @@ In `"skip"` and `"scan_anyway"`, `tier3_chunk_summary.oversize` is `True`.
255
265
 
256
266
  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
267
 
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.
268
+ **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.
259
269
 
260
270
  **Policy options** (all in `tier3={...}`; camelCase keys work too):
261
271
 
262
272
  | Option | Default | What it does |
263
273
  |---|---|---|
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"`. |
274
+ | `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`. |
275
+ | `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 (with the bundled model Tier 2 rarely reports `medium`: its band is [0.5, 0.52)); 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"`. |
276
+ | `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`. |
267
277
  | `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. |
278
+ | `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. |
269
279
  | `stop_on_first_block` | `False` | tier3_only: cancel the remaining chunk calls once one blocks. |
270
280
  | `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
281
  | `include_raw` | `True` | Keep `raw` on `result.tier3`. `raw` can quote tool content. |
272
282
 
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.
283
+ **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`.
284
+
285
+ - `allowed` is the final answer. With `allow_override=False`, `result.tier3.decision` can be `"allow"` while `allowed` is `False`.
286
+ - 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.
287
+ - 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.
288
+ - 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.
274
289
 
275
290
  **Cancellation.** Cancelling the task that awaits `defend_tool_result_async` cancels in-flight provider calls and raises `CancelledError`.
276
291
 
@@ -280,7 +295,7 @@ A failed chunk is skipped (fail open), and `result.tier3.errored` and `coverage_
280
295
 
281
296
  - **`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.
282
297
  - Use **`allowed`** for gating when `block_high_risk=True`: `False` means do not pass `sanitized` to the model as-is.
283
- - **`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.
298
+ - **`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.
284
299
 
285
300
  | Level | Typical trigger |
286
301
  |-------|------------------|
@@ -300,20 +315,21 @@ defense = create_prompt_defense(
300
315
  block_high_risk=False,
301
316
  default_risk_level="low",
302
317
  annotate_boundary=False, # True: wrap risky strings with [UD-…] tags (npm: annotateBoundary)
318
+ sanitize_content=True, # False: detect-and-gate; sanitized is the input verbatim
303
319
  tier2_fields=["subject", "body", "snippet"], # optional: scope Tier 2 to these JSON keys (default: all strings)
304
- use_sfe=True, # optional: enable semantic field extractor preprocessing
305
- config={
306
- "tier2": {
307
- "high_risk_threshold": 0.8,
308
- "tier2_fields": None, # or list[str]; constructor tier2_fields wins if set
309
- },
310
- },
320
+ tier2_config={"high_risk_threshold": 0.52}, # optional; defaults come from the bundled model's classifier_config.json
321
+ use_sfe=False, # True, or {"threshold": 0.5, "predictor": ...}: SFE preprocessing before Tier 2
322
+ enable_tier3=False,
323
+ defender_mode="cascade", # or "tier3_only"; ignored unless enable_tier3
324
+ tier3=None, # dict of Tier 3 options (see Tier 3 above)
311
325
  )
312
326
  ```
313
327
 
328
+ Set Tier 2 thresholds with `tier2_config`. The bundled model's calibrated values override library defaults, so threshold keys under `config["tier2"]` are ignored.
329
+
314
330
  ### `defense.defend_tool_result(value, tool_name, *, context=None)`
315
331
 
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`.
332
+ 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.
317
333
 
318
334
  ```python
319
335
  from dataclasses import dataclass, field
@@ -329,7 +345,7 @@ class DefenseResult:
329
345
  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)
330
346
  tier2_score: float | None = None
331
347
  tier2_raw_score: float | None = None
332
- tier2_aux_score: float | None = None # multi-head models only
348
+ tier2_aux_score: float | None = None # set only when tier2_config["multihead"] is set
333
349
  tier2_multihead_blocked: bool | None = None
334
350
  tier2_skip_reason: str | None = None
335
351
  max_sentence: str | None = None
@@ -350,7 +366,7 @@ class DefenseResult:
350
366
 
351
367
  ### `defense.defend_tool_results(items)`
352
368
 
353
- 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`.
369
+ 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`.
354
370
 
355
371
  ```python
356
372
  results = defense.defend_tool_results([
@@ -382,7 +398,7 @@ Tier 1 only — useful for debugging pattern hits without full tool-result trave
382
398
 
383
399
  ```python
384
400
  defense = create_prompt_defense()
385
- defense.warmup_tier2() # no-op if enable_tier2=False or ONNX extra missing
401
+ defense.warmup_tier2() # no-op if enable_tier2=False; warns (or raises with require_tier2=True) if the [onnx] extra is missing
386
402
  ```
387
403
 
388
404
  ## Integration example
@@ -405,13 +421,13 @@ sanitized = run_tool_and_defend(gmail_api.get_message(msg_id), "gmail_get_messag
405
421
 
406
422
  ## Risky field detection
407
423
 
408
- 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.)
424
+ 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.
409
425
 
410
426
  | Tool pattern | Scanned fields |
411
427
  |--------------|----------------|
412
428
  | `gmail_*`, `email_*` | subject, body, snippet, content |
413
429
  | `documents_*` | name, description, content, title |
414
- | `github_*` | name, title, body, description, message |
430
+ | `github_*` | name, title, body, description, message, content |
415
431
  | `hris_*` | name, notes, bio, description |
416
432
  | `ats_*` | name, notes, description, summary |
417
433
  | `crm_*` | name, description, notes, content |
@@ -420,7 +436,10 @@ Otherwise the default list applies: `name`, `description`, `content`, `title`, `
420
436
 
421
437
  ## Development
422
438
 
439
+ 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.
440
+
423
441
  ```bash
442
+ git lfs install && git lfs pull
424
443
  uv sync --group dev
425
444
  uv run pytest
426
445
  ```
@@ -444,6 +463,15 @@ Traps when porting from TypeScript (the fixtures pin TS behavior):
444
463
  - JS `trim()` removes a BOM, but `str.strip()` doesn't.
445
464
  - `json.loads` accepts `NaN`, but JS `JSON.parse` rejects it. Use `parse_constant` to reject it.
446
465
 
466
+ ## Defender by StackOne
467
+
468
+ 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.
469
+
470
+ - **Defender site** — [defender.stackone.com](https://defender.stackone.com?utm_source=pypi&utm_medium=readme&utm_campaign=about)
471
+ - **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)
472
+ - **StackOne platform** — [stackone.com](https://www.stackone.com?utm_source=pypi&utm_medium=readme&utm_campaign=about)
473
+ - **TypeScript package** — [`@stackone/defender`](https://www.npmjs.com/package/@stackone/defender) ([StackOneHQ/defender](https://github.com/StackOneHQ/defender))
474
+
447
475
  ## License
448
476
 
449
477
  Apache-2.0 — see [LICENSE](./LICENSE).