stackone-defender 0.9.1__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.
- stackone_defender-0.9.2/.gitattributes +2 -0
- stackone_defender-0.9.2/.release-please-manifest.json +1 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/CHANGELOG.md +12 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/PKG-INFO +76 -51
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/README.md +71 -48
- stackone_defender-0.9.2/hatch_build.py +39 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/pyproject.toml +9 -3
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/src/stackone_defender/classifiers/onnx_classifier.py +3 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/src/stackone_defender/sfe/preprocess.py +2 -0
- stackone_defender-0.9.2/src/stackone_defender/utils/lfs.py +28 -0
- stackone_defender-0.9.2/tests/test_lfs.py +90 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/uv.lock +46 -1
- stackone_defender-0.9.1/.release-please-manifest.json +0 -1
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/.gitignore +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/.python-version +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/.release-please-config.json +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/LICENSE +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/prof.out +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/scripts/sync_conformance.sh +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/src/stackone_defender/__init__.py +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/src/stackone_defender/classifiers/__init__.py +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/src/stackone_defender/classifiers/pattern_detector.py +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/src/stackone_defender/classifiers/patterns.py +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/src/stackone_defender/classifiers/tier2_classifier.py +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/src/stackone_defender/classifiers/tier3_llm_provider.py +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/src/stackone_defender/classifiers/tier3_openai_compatible.py +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/src/stackone_defender/classifiers/tier3_orchestrator.py +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/src/stackone_defender/config.py +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/src/stackone_defender/core/__init__.py +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/src/stackone_defender/core/prompt_defense.py +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/src/stackone_defender/core/sentence_cleaner.py +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/src/stackone_defender/core/tier3_input.py +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/src/stackone_defender/core/tier3_policy.py +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/src/stackone_defender/core/tool_result_sanitizer.py +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/src/stackone_defender/models/minilm-multihead-v5/classifier_config.json +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/src/stackone_defender/models/minilm-multihead-v5/config.json +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/src/stackone_defender/models/minilm-multihead-v5/model_quantized.onnx +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/src/stackone_defender/models/minilm-multihead-v5/tokenizer.json +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/src/stackone_defender/models/minilm-multihead-v5/tokenizer_config.json +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/src/stackone_defender/py.typed +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/src/stackone_defender/sanitizers/__init__.py +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/src/stackone_defender/sanitizers/encoding_detector.py +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/src/stackone_defender/sanitizers/leet_normalizer.py +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/src/stackone_defender/sanitizers/normalizer.py +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/src/stackone_defender/sanitizers/role_stripper.py +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/src/stackone_defender/sfe/__init__.py +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/src/stackone_defender/sfe/model.ftz +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/src/stackone_defender/types.py +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/src/stackone_defender/utils/__init__.py +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/src/stackone_defender/utils/boundary.py +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/src/stackone_defender/utils/field_detection.py +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/src/stackone_defender/utils/redact.py +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/src/stackone_defender/utils/structure.py +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/src/stackone_defender/utils/text.py +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/tests/__init__.py +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/tests/conformance/README.md +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/tests/conformance/VERSION +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/tests/conformance/tier3-parse.json +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/tests/conformance/tier3-policy.json +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/tests/conformance/tier3-prompt.json +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/tests/conformance/tier3-redact.json +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/tests/conformance/tier3-serialize.json +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/tests/conformance_known_failures.txt +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/tests/test_conformance.py +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/tests/test_integration.py +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/tests/test_onnx_classifier.py +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/tests/test_pattern_detector.py +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/tests/test_sanitizers.py +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/tests/test_sentence_cleaner.py +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/tests/test_sfe.py +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/tests/test_tier2_classifier.py +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/tests/test_tier3.py +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/tests/test_tier3_policy.py +0 -0
- {stackone_defender-0.9.1 → stackone_defender-0.9.2}/tests/test_utils.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{".":"0.9.2"}
|
|
@@ -1,5 +1,17 @@
|
|
|
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
|
+
|
|
3
15
|
## [0.9.1](https://github.com/StackOneHQ/defender-py/compare/stackone-defender-v0.9.0...stackone-defender-v0.9.1) (2026-10-20)
|
|
4
16
|
|
|
5
17
|
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: stackone-defender
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.2
|
|
4
4
|
Summary: Indirect prompt injection defense for AI agents using tool calls
|
|
5
|
-
Project-URL: Homepage, https://
|
|
6
|
-
Project-URL: Repository, https://github.com/StackOneHQ/
|
|
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,17 +30,20 @@ Description-Content-Type: text/markdown
|
|
|
28
30
|
|
|
29
31
|
<div align="center">
|
|
30
32
|
|
|
31
|
-
<
|
|
32
|
-
<
|
|
33
|
-
|
|
34
|
-
|
|
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/
|
|
39
|
-
<a href="https://github.com/StackOneHQ/
|
|
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" />
|
|
@@ -51,7 +56,7 @@ Description-Content-Type: text/markdown
|
|
|
51
56
|
|
|
52
57
|
---
|
|
53
58
|
|
|
54
|
-
Indirect prompt injection defense for AI agents using tool calls (MCP, CLI, or direct APIs). Detects and
|
|
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
|
-
|
|
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
|
-
|
|
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,8 @@ 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. 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.
|
|
145
147
|
|
|
146
148
|
### Optional SFE preprocessor
|
|
147
149
|
|
|
@@ -161,7 +163,7 @@ Packed-chunk MiniLM classifier (int8 ONNX ~22 MB, bundled):
|
|
|
161
163
|
|
|
162
164
|
### Optional Tier 3 — LLM adjudication (bring your own LLM)
|
|
163
165
|
|
|
164
|
-
|
|
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:
|
|
165
167
|
|
|
166
168
|
1. **`create_openai_compatible_tier3_provider`**: any OpenAI-compatible `/chat/completions` endpoint (OpenAI, vLLM, Ollama, OpenRouter, LiteLLM). Needs the `[llm]` extra.
|
|
167
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.
|
|
@@ -169,7 +171,7 @@ Authoritative LLM-based classification for the cases Tier 2 finds ambiguous. You
|
|
|
169
171
|
|
|
170
172
|
Two modes, selected via `defender_mode`:
|
|
171
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.
|
|
172
|
-
- **`"tier3_only"`**: skip Tier 2
|
|
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.
|
|
173
175
|
|
|
174
176
|
```python
|
|
175
177
|
import os
|
|
@@ -187,12 +189,16 @@ defense = create_prompt_defense(
|
|
|
187
189
|
defender_mode="tier3_only",
|
|
188
190
|
tier3={"provider": judge, "timeout_ms": 10_000, "on_error": "block"},
|
|
189
191
|
)
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
+
)
|
|
193
197
|
```
|
|
194
198
|
|
|
195
|
-
|
|
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.
|
|
200
|
+
|
|
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:
|
|
196
202
|
|
|
197
203
|
```python
|
|
198
204
|
import time
|
|
@@ -208,15 +214,14 @@ async def complete(req):
|
|
|
208
214
|
judge = create_llm_tier3_provider(complete=complete)
|
|
209
215
|
```
|
|
210
216
|
|
|
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
|
|
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.
|
|
212
218
|
|
|
213
219
|
A hand-written provider, registered once at startup:
|
|
214
220
|
|
|
215
221
|
```python
|
|
216
222
|
from typing import Any
|
|
217
223
|
|
|
218
|
-
from stackone_defender import create_prompt_defense, set_default_tier3_provider
|
|
219
|
-
from stackone_defender.types import Tier3Verdict
|
|
224
|
+
from stackone_defender import Tier3Verdict, create_prompt_defense, set_default_tier3_provider
|
|
220
225
|
|
|
221
226
|
class MyProvider:
|
|
222
227
|
def classify(self, text: str, *, ctx: dict[str, Any] | None = None) -> Tier3Verdict:
|
|
@@ -242,6 +247,8 @@ defense = create_prompt_defense(
|
|
|
242
247
|
)
|
|
243
248
|
```
|
|
244
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
|
+
|
|
245
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)`.
|
|
246
253
|
|
|
247
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`.
|
|
@@ -255,22 +262,27 @@ In `"skip"` and `"scan_anyway"`, `tier3_chunk_summary.oversize` is `True`.
|
|
|
255
262
|
|
|
256
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.
|
|
257
264
|
|
|
258
|
-
**Choosing the operating point (`block_threshold`).** By default the model's `decision` word
|
|
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.
|
|
259
266
|
|
|
260
267
|
**Policy options** (all in `tier3={...}`; camelCase keys work too):
|
|
261
268
|
|
|
262
269
|
| Option | Default | What it does |
|
|
263
270
|
|---|---|---|
|
|
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
|
|
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"`. |
|
|
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`. |
|
|
267
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. |
|
|
268
|
-
| `max_concurrency` | unbounded | Max in-flight provider calls per instance
|
|
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. |
|
|
269
276
|
| `stop_on_first_block` | `False` | tier3_only: cancel the remaining chunk calls once one blocks. |
|
|
270
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. |
|
|
271
278
|
| `include_raw` | `True` | Keep `raw` on `result.tier3`. `raw` can quote tool content. |
|
|
272
279
|
|
|
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.
|
|
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.
|
|
274
286
|
|
|
275
287
|
**Cancellation.** Cancelling the task that awaits `defend_tool_result_async` cancels in-flight provider calls and raises `CancelledError`.
|
|
276
288
|
|
|
@@ -280,7 +292,7 @@ A failed chunk is skipped (fail open), and `result.tier3.errored` and `coverage_
|
|
|
280
292
|
|
|
281
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.
|
|
282
294
|
- 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
|
|
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.
|
|
284
296
|
|
|
285
297
|
| Level | Typical trigger |
|
|
286
298
|
|-------|------------------|
|
|
@@ -300,20 +312,21 @@ defense = create_prompt_defense(
|
|
|
300
312
|
block_high_risk=False,
|
|
301
313
|
default_risk_level="low",
|
|
302
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
|
|
303
316
|
tier2_fields=["subject", "body", "snippet"], # optional: scope Tier 2 to these JSON keys (default: all strings)
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
},
|
|
310
|
-
},
|
|
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)
|
|
311
322
|
)
|
|
312
323
|
```
|
|
313
324
|
|
|
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.
|
|
326
|
+
|
|
314
327
|
### `defense.defend_tool_result(value, tool_name, *, context=None)`
|
|
315
328
|
|
|
316
|
-
Runs Tier 1
|
|
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.
|
|
317
330
|
|
|
318
331
|
```python
|
|
319
332
|
from dataclasses import dataclass, field
|
|
@@ -329,7 +342,7 @@ class DefenseResult:
|
|
|
329
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)
|
|
330
343
|
tier2_score: float | None = None
|
|
331
344
|
tier2_raw_score: float | None = None
|
|
332
|
-
tier2_aux_score: float | None = None #
|
|
345
|
+
tier2_aux_score: float | None = None # set only when tier2_config["multihead"] is set
|
|
333
346
|
tier2_multihead_blocked: bool | None = None
|
|
334
347
|
tier2_skip_reason: str | None = None
|
|
335
348
|
max_sentence: str | None = None
|
|
@@ -350,7 +363,7 @@ class DefenseResult:
|
|
|
350
363
|
|
|
351
364
|
### `defense.defend_tool_results(items)`
|
|
352
365
|
|
|
353
|
-
Sync batch API.
|
|
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`.
|
|
354
367
|
|
|
355
368
|
```python
|
|
356
369
|
results = defense.defend_tool_results([
|
|
@@ -382,7 +395,7 @@ Tier 1 only — useful for debugging pattern hits without full tool-result trave
|
|
|
382
395
|
|
|
383
396
|
```python
|
|
384
397
|
defense = create_prompt_defense()
|
|
385
|
-
defense.warmup_tier2() # no-op if enable_tier2=False or
|
|
398
|
+
defense.warmup_tier2() # no-op if enable_tier2=False; warns (or raises with require_tier2=True) if the [onnx] extra is missing
|
|
386
399
|
```
|
|
387
400
|
|
|
388
401
|
## Integration example
|
|
@@ -405,13 +418,13 @@ sanitized = run_tool_and_defend(gmail_api.get_message(msg_id), "gmail_get_messag
|
|
|
405
418
|
|
|
406
419
|
## Risky field detection
|
|
407
420
|
|
|
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/
|
|
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.
|
|
409
422
|
|
|
410
423
|
| Tool pattern | Scanned fields |
|
|
411
424
|
|--------------|----------------|
|
|
412
425
|
| `gmail_*`, `email_*` | subject, body, snippet, content |
|
|
413
426
|
| `documents_*` | name, description, content, title |
|
|
414
|
-
| `github_*` | name, title, body, description, message |
|
|
427
|
+
| `github_*` | name, title, body, description, message, content |
|
|
415
428
|
| `hris_*` | name, notes, bio, description |
|
|
416
429
|
| `ats_*` | name, notes, description, summary |
|
|
417
430
|
| `crm_*` | name, description, notes, content |
|
|
@@ -420,7 +433,10 @@ Otherwise the default list applies: `name`, `description`, `content`, `title`, `
|
|
|
420
433
|
|
|
421
434
|
## Development
|
|
422
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
|
+
|
|
423
438
|
```bash
|
|
439
|
+
git lfs install && git lfs pull
|
|
424
440
|
uv sync --group dev
|
|
425
441
|
uv run pytest
|
|
426
442
|
```
|
|
@@ -444,6 +460,15 @@ Traps when porting from TypeScript (the fixtures pin TS behavior):
|
|
|
444
460
|
- JS `trim()` removes a BOM, but `str.strip()` doesn't.
|
|
445
461
|
- `json.loads` accepts `NaN`, but JS `JSON.parse` rejects it. Use `parse_constant` to reject it.
|
|
446
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
|
+
|
|
447
472
|
## License
|
|
448
473
|
|
|
449
474
|
Apache-2.0 — see [LICENSE](./LICENSE).
|