stackone-defender 0.9.0__tar.gz → 0.9.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- stackone_defender-0.9.1/.release-please-manifest.json +1 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/CHANGELOG.md +7 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/PKG-INFO +81 -9
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/README.md +78 -8
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/pyproject.toml +4 -1
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/__init__.py +22 -0
- stackone_defender-0.9.1/src/stackone_defender/classifiers/tier3_llm_provider.py +367 -0
- stackone_defender-0.9.1/src/stackone_defender/classifiers/tier3_openai_compatible.py +250 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/core/prompt_defense.py +245 -155
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/core/tier3_input.py +2 -51
- stackone_defender-0.9.1/src/stackone_defender/core/tier3_policy.py +490 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/types.py +90 -4
- stackone_defender-0.9.1/src/stackone_defender/utils/redact.py +99 -0
- stackone_defender-0.9.1/src/stackone_defender/utils/text.py +97 -0
- stackone_defender-0.9.1/tests/conformance_known_failures.txt +5 -0
- stackone_defender-0.9.1/tests/test_tier3_policy.py +580 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/uv.lock +8 -2
- stackone_defender-0.9.0/.release-please-manifest.json +0 -1
- stackone_defender-0.9.0/tests/conformance_known_failures.txt +0 -97
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/.gitignore +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/.python-version +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/.release-please-config.json +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/LICENSE +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/prof.out +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/scripts/sync_conformance.sh +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/classifiers/__init__.py +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/classifiers/onnx_classifier.py +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/classifiers/pattern_detector.py +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/classifiers/patterns.py +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/classifiers/tier2_classifier.py +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/classifiers/tier3_orchestrator.py +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/config.py +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/core/__init__.py +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/core/sentence_cleaner.py +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/core/tool_result_sanitizer.py +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/models/minilm-multihead-v5/classifier_config.json +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/models/minilm-multihead-v5/config.json +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/models/minilm-multihead-v5/model_quantized.onnx +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/models/minilm-multihead-v5/tokenizer.json +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/models/minilm-multihead-v5/tokenizer_config.json +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/py.typed +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/sanitizers/__init__.py +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/sanitizers/encoding_detector.py +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/sanitizers/leet_normalizer.py +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/sanitizers/normalizer.py +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/sanitizers/role_stripper.py +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/sfe/__init__.py +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/sfe/model.ftz +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/sfe/preprocess.py +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/utils/__init__.py +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/utils/boundary.py +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/utils/field_detection.py +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/src/stackone_defender/utils/structure.py +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/tests/__init__.py +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/tests/conformance/README.md +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/tests/conformance/VERSION +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/tests/conformance/tier3-parse.json +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/tests/conformance/tier3-policy.json +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/tests/conformance/tier3-prompt.json +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/tests/conformance/tier3-redact.json +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/tests/conformance/tier3-serialize.json +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/tests/test_conformance.py +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/tests/test_integration.py +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/tests/test_onnx_classifier.py +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/tests/test_pattern_detector.py +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/tests/test_sanitizers.py +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/tests/test_sentence_cleaner.py +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/tests/test_sfe.py +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/tests/test_tier2_classifier.py +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/tests/test_tier3.py +0 -0
- {stackone_defender-0.9.0 → stackone_defender-0.9.1}/tests/test_utils.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{".":"0.9.1"}
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.9.1](https://github.com/StackOneHQ/defender-py/compare/stackone-defender-v0.9.0...stackone-defender-v0.9.1) (2026-10-20)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **ENG-3500:** bring-your-own-LLM Tier 3 and policy options ([#38](https://github.com/StackOneHQ/defender-py/issues/38)) ([9923491](https://github.com/StackOneHQ/defender-py/commit/9923491c413bafa09d8aee0c47aabd43ed049d09))
|
|
9
|
+
|
|
3
10
|
## [0.9.0](https://github.com/StackOneHQ/defender-py/compare/stackone-defender-v0.8.2...stackone-defender-v0.9.0) (2026-10-20)
|
|
4
11
|
|
|
5
12
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: stackone-defender
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.1
|
|
4
4
|
Summary: Indirect prompt injection defense for AI agents using tool calls
|
|
5
5
|
Project-URL: Homepage, https://github.com/StackOneHQ/stackone-defender
|
|
6
6
|
Project-URL: Repository, https://github.com/StackOneHQ/stackone-defender
|
|
@@ -16,6 +16,8 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
16
16
|
Classifier: Topic :: Security
|
|
17
17
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
18
|
Requires-Python: >=3.11
|
|
19
|
+
Provides-Extra: llm
|
|
20
|
+
Requires-Dist: httpx>=0.27; extra == 'llm'
|
|
19
21
|
Provides-Extra: onnx
|
|
20
22
|
Requires-Dist: numpy>=1.24.0; extra == 'onnx'
|
|
21
23
|
Requires-Dist: onnxruntime>=1.16.0; extra == 'onnx'
|
|
@@ -83,6 +85,15 @@ pip install stackone-defender[sfe]
|
|
|
83
85
|
# or: uv add "stackone-defender[sfe]"
|
|
84
86
|
```
|
|
85
87
|
|
|
88
|
+
**Tier 3 over an OpenAI-compatible endpoint (optional)** — add extras:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
pip install stackone-defender[llm]
|
|
92
|
+
# or: uv add "stackone-defender[llm]"
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The `[llm]` extra installs [`httpx`](https://www.python-httpx.org/) for `create_openai_compatible_tier3_provider`. `create_llm_tier3_provider` needs no extra.
|
|
96
|
+
|
|
86
97
|
The `[sfe]` extra installs [`fasttext-ng`](https://pypi.org/project/fasttext-ng/) (provides the `fasttext` module). It requires **NumPy 2.3+**. PyPI may ship a wheel only for some platforms; otherwise pip/uv builds from source (needs a C++ toolchain).
|
|
87
98
|
|
|
88
99
|
## Quick start
|
|
@@ -148,15 +159,58 @@ Packed-chunk MiniLM classifier (int8 ONNX ~22 MB, bundled):
|
|
|
148
159
|
| jayavibhav (adversarial) | 0.9717 | ~1k |
|
|
149
160
|
| **Average** | **0.9079** | ~25k |
|
|
150
161
|
|
|
151
|
-
### Optional Tier 3 — LLM adjudication (
|
|
162
|
+
### Optional Tier 3 — LLM adjudication (bring your own LLM)
|
|
163
|
+
|
|
164
|
+
Authoritative LLM-based classification for the cases Tier 2 finds ambiguous. You bring the model. Defender gives you three ways to plug it in:
|
|
152
165
|
|
|
153
|
-
|
|
166
|
+
1. **`create_openai_compatible_tier3_provider`**: any OpenAI-compatible `/chat/completions` endpoint (OpenAI, vLLM, Ollama, OpenRouter, LiteLLM). Needs the `[llm]` extra.
|
|
167
|
+
2. **`create_llm_tier3_provider`**: any other LLM. You write one `complete` function that calls the model; defender owns the prompt, the untrusted-text markers, strict reply parsing and refusal handling.
|
|
168
|
+
3. **A hand-written provider**: an object with `classify(text, *, ctx=None)` that owns everything itself.
|
|
154
169
|
|
|
155
170
|
Two modes, selected via `defender_mode`:
|
|
156
171
|
- **`"cascade"`** (default): Tier 1 → Tier 2 → Tier 3, with Tier 3 invoked only when the Tier 2 effective score falls in the gray band (default `[0.3, 0.85)`). The Tier 3 verdict overrides Tier 2 on the escalated chunk — a `block` forces a block, an `allow` rescues it. Outside the band defender skips the round trip.
|
|
157
172
|
- **`"tier3_only"`**: skip Tier 2; the block/allow decision is the Tier 3 verdict alone. Tier 1 still runs detection; the returned `sanitized` payload is the original content.
|
|
158
173
|
|
|
159
|
-
|
|
174
|
+
```python
|
|
175
|
+
import os
|
|
176
|
+
|
|
177
|
+
from stackone_defender import create_openai_compatible_tier3_provider, create_prompt_defense
|
|
178
|
+
|
|
179
|
+
judge = create_openai_compatible_tier3_provider(
|
|
180
|
+
base_url="https://api.openai.com/v1",
|
|
181
|
+
api_key=os.environ["OPENAI_API_KEY"],
|
|
182
|
+
model="gpt-4.1-mini",
|
|
183
|
+
)
|
|
184
|
+
defense = create_prompt_defense(
|
|
185
|
+
block_high_risk=True,
|
|
186
|
+
enable_tier3=True,
|
|
187
|
+
defender_mode="tier3_only",
|
|
188
|
+
tier3={"provider": judge, "timeout_ms": 10_000, "on_error": "block"},
|
|
189
|
+
)
|
|
190
|
+
result = await defense.defend_tool_result_async(
|
|
191
|
+
tool_output, "gmail_get_message", context={"user_goal": "Summarize my unread mail"}
|
|
192
|
+
)
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Around any other LLM, `complete` gets a request with `messages` (system + user), `deadline` and `json_schema`, and returns the reply text (sync or async):
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
import time
|
|
199
|
+
|
|
200
|
+
from stackone_defender import create_llm_tier3_provider
|
|
201
|
+
|
|
202
|
+
async def complete(req):
|
|
203
|
+
# req.deadline is absolute (time.time() seconds), or None when tier3.timeout_ms isn't set.
|
|
204
|
+
timeout = req.deadline - time.time() if req.deadline else 30
|
|
205
|
+
reply = await my_llm.chat(req.messages, timeout=timeout)
|
|
206
|
+
return reply.text # or {"text": ..., "refused": True} / {"text": ..., "score": p_block}
|
|
207
|
+
|
|
208
|
+
judge = create_llm_tier3_provider(complete=complete)
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
The default prompt (`DEFAULT_TIER3_PROMPT`, version `TIER3_PROMPT_VERSION`) wraps the tool output, user goal and tool description in random-id markers and asks for `{"decision": "block" | "allow", "reason": "..."}`. Pass `prompt=Tier3PromptTemplate(system=..., user=...)` (the `user` part must contain `{{content}}` once; also `{{toolName}}`, `{{userGoal}}`, `{{toolDescription}}`) or a builder function that gets a `PromptInput`. A refusal, a content-filter hit, or a reply that isn't exactly one JSON verdict **blocks** (`result.tier3.refused`), because attacker text can cause those on purpose. `result.tier3.reason` is model output that can quote the attacker: log it, but never feed it back to an agent.
|
|
212
|
+
|
|
213
|
+
A hand-written provider, registered once at startup:
|
|
160
214
|
|
|
161
215
|
```python
|
|
162
216
|
from typing import Any
|
|
@@ -166,8 +220,9 @@ from stackone_defender.types import Tier3Verdict
|
|
|
166
220
|
|
|
167
221
|
class MyProvider:
|
|
168
222
|
def classify(self, text: str, *, ctx: dict[str, Any] | None = None) -> Tier3Verdict:
|
|
169
|
-
# Call your LLM endpoint here (sync or awaitable). ctx
|
|
170
|
-
|
|
223
|
+
# Call your LLM endpoint here (sync or awaitable). ctx has tool_name, mode, chunk,
|
|
224
|
+
# user_goal / tool_description (untrusted), and deadline when timeout_ms is set.
|
|
225
|
+
verdict = call_my_llm(text, tool_name=(ctx or {}).get("tool_name"))
|
|
171
226
|
return Tier3Verdict(decision="block" if verdict.block else "allow", score=verdict.p_block)
|
|
172
227
|
|
|
173
228
|
set_default_tier3_provider(MyProvider())
|
|
@@ -202,7 +257,24 @@ A failed chunk is skipped (fail open), and `result.tier3.errored` and `coverage_
|
|
|
202
257
|
|
|
203
258
|
**Choosing the operating point (`block_threshold`).** By default the model's `decision` word is authoritative — but that word is its argmax, an implicit 0.5 cut that moves on its own whenever the model is retrained. Set `tier3.block_threshold` to decide on `verdict.score` (P(block)) instead: the cut becomes an explicit config value — raise it to trade recall for fewer false positives, lower it for the reverse; `0.5` reproduces argmax. It requires a provider that reports `score` as P(block) (not "confidence in whichever decision I made" — those invert on allows). If `score` is missing or outside `[0, 1]` the verdict's `decision` is used instead and defender warns once, so a provider that cannot report a score degrades to the default rather than failing.
|
|
204
259
|
|
|
205
|
-
**
|
|
260
|
+
**Policy options** (all in `tier3={...}`; camelCase keys work too):
|
|
261
|
+
|
|
262
|
+
| Option | Default | What it does |
|
|
263
|
+
|---|---|---|
|
|
264
|
+
| `allow_override` | `True` | Cascade: whether a Tier-3 `allow` can undo a Tier-2 block. `False`: Tier 3 only adds blocks. `{"max_tier2_score": x}`: only when Tier 2's undamped score is below `x`. A general-purpose LLM can be talked into `allow`, so prefer `False` or a cap (defender warns once otherwise). |
|
|
265
|
+
| `on_error` | cascade `"tier2"`, tier3_only `"allow"` | What a failure (error, timeout, invalid verdict) does. `"tier2"` keeps Tier 2's verdict. `"allow"` treats it as an allow. `"block"` blocks: in cascade when Tier 2 rated the payload medium or higher; in tier3_only when any chunk failed (and `on_oversize` then defaults to `"scan_anyway"`). A missing provider fails closed too. |
|
|
266
|
+
| `decide` | none | `decide(Tier3DecisionInput) -> "block" \| "allow" \| "default"`, called once per call after Tier 3 ran or failed. Replaces `allow_override` and `on_error` for that call. Gets metadata only (never content). A raise or bad return means `"default"`. |
|
|
267
|
+
| `timeout_ms` | none | Per provider call, from when it gets a concurrency slot. An async provider is cancelled at the deadline (`ctx["deadline"]`); a sync one can't be interrupted. |
|
|
268
|
+
| `max_concurrency` | unbounded | Max in-flight provider calls per instance (per event loop). Set `timeout_ms` with it. |
|
|
269
|
+
| `stop_on_first_block` | `False` | tier3_only: cancel the remaining chunk calls once one blocks. |
|
|
270
|
+
| `on_event` | none | Called once per provider call with a `Tier3Event` (kind, mode, chunk, latency, usage, redacted error code). No content. Exceptions are ignored. |
|
|
271
|
+
| `include_raw` | `True` | Keep `raw` on `result.tier3`. `raw` can quote tool content. |
|
|
272
|
+
|
|
273
|
+
**Result fields.** `result.tier3` is a `Tier3Verdict` (or a `Tier3Skip` when no verdict was obtained) and `None` when Tier 3 didn't run. Besides `decision`, `score`, `reason`, `refused` and `prompt_version`, defender sets: `errored` (a real failure), `note` (why the outcome isn't the verdict's alone), `override_applied` / `override_suppressed` (cascade), and `decided_by` (`"policy"` or `"options"` when `decide` is set). Provider error text in `skip_reason`, `note` and events is redacted (keys, tokens, URL paths) and capped.
|
|
274
|
+
|
|
275
|
+
**Cancellation.** Cancelling the task that awaits `defend_tool_result_async` cancels in-flight provider calls and raises `CancelledError`.
|
|
276
|
+
|
|
277
|
+
**Fail-open by default.** Without `on_error="block"`, a provider error or timeout records a `skip_reason`; in cascade defender falls back to the Tier 2 decision, in `tier3_only` it allows. `enable_tier3=True` with no provider (and no `on_error="block"`) falls back to Tier 1 + Tier 2 and logs one warning per instance, so Tier 3 misconfiguration never silently disables defense.
|
|
206
278
|
|
|
207
279
|
### `allowed` vs `risk_level`
|
|
208
280
|
|
|
@@ -239,7 +311,7 @@ defense = create_prompt_defense(
|
|
|
239
311
|
)
|
|
240
312
|
```
|
|
241
313
|
|
|
242
|
-
### `defense.defend_tool_result(value, tool_name)`
|
|
314
|
+
### `defense.defend_tool_result(value, tool_name, *, context=None)`
|
|
243
315
|
|
|
244
316
|
Runs Tier 1 sanitization on risky fields of the **original** payload, then Tier 2 on strings from the SFE-filtered view when SFE is on (otherwise the full value). Optional `tier2_fields` restricts Tier 2 extraction to specific keys; omit it to classify **all** strings (matches `@stackone/defender` 0.6.3). **Synchronous** — no `await`.
|
|
245
317
|
|
|
@@ -358,7 +430,7 @@ uv run pytest
|
|
|
358
430
|
This package mirrors [`@stackone/defender`](https://github.com/StackOneHQ/defender). `tests/conformance/` is a pinned copy of its shared conformance fixtures: input → expected-output cases for Tier-3 serialization, prompt rendering, verdict parsing, error redaction and policy decisions. `tests/test_conformance.py` runs them against this package.
|
|
359
431
|
|
|
360
432
|
- `scripts/sync_conformance.sh <ref>` copies the fixtures at a TS tag or commit and records it in `tests/conformance/VERSION`. CI checks the copy matches (`--check`).
|
|
361
|
-
-
|
|
433
|
+
- Every case passes. A new TS case this package can't match yet goes in `tests/conformance_known_failures.txt`, where it runs as a strict xfail; remove its line once a port makes it pass.
|
|
362
434
|
- Port contract for the runner: a rejected prompt template and an unusable verdict reply raise `ValueError`; optional result fields are `None` when unset (not `False`).
|
|
363
435
|
|
|
364
436
|
Traps when porting from TypeScript (the fixtures pin TS behavior):
|
|
@@ -57,6 +57,15 @@ pip install stackone-defender[sfe]
|
|
|
57
57
|
# or: uv add "stackone-defender[sfe]"
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
+
**Tier 3 over an OpenAI-compatible endpoint (optional)** — add extras:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install stackone-defender[llm]
|
|
64
|
+
# or: uv add "stackone-defender[llm]"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The `[llm]` extra installs [`httpx`](https://www.python-httpx.org/) for `create_openai_compatible_tier3_provider`. `create_llm_tier3_provider` needs no extra.
|
|
68
|
+
|
|
60
69
|
The `[sfe]` extra installs [`fasttext-ng`](https://pypi.org/project/fasttext-ng/) (provides the `fasttext` module). It requires **NumPy 2.3+**. PyPI may ship a wheel only for some platforms; otherwise pip/uv builds from source (needs a C++ toolchain).
|
|
61
70
|
|
|
62
71
|
## Quick start
|
|
@@ -122,15 +131,58 @@ Packed-chunk MiniLM classifier (int8 ONNX ~22 MB, bundled):
|
|
|
122
131
|
| jayavibhav (adversarial) | 0.9717 | ~1k |
|
|
123
132
|
| **Average** | **0.9079** | ~25k |
|
|
124
133
|
|
|
125
|
-
### Optional Tier 3 — LLM adjudication (
|
|
134
|
+
### Optional Tier 3 — LLM adjudication (bring your own LLM)
|
|
135
|
+
|
|
136
|
+
Authoritative LLM-based classification for the cases Tier 2 finds ambiguous. You bring the model. Defender gives you three ways to plug it in:
|
|
126
137
|
|
|
127
|
-
|
|
138
|
+
1. **`create_openai_compatible_tier3_provider`**: any OpenAI-compatible `/chat/completions` endpoint (OpenAI, vLLM, Ollama, OpenRouter, LiteLLM). Needs the `[llm]` extra.
|
|
139
|
+
2. **`create_llm_tier3_provider`**: any other LLM. You write one `complete` function that calls the model; defender owns the prompt, the untrusted-text markers, strict reply parsing and refusal handling.
|
|
140
|
+
3. **A hand-written provider**: an object with `classify(text, *, ctx=None)` that owns everything itself.
|
|
128
141
|
|
|
129
142
|
Two modes, selected via `defender_mode`:
|
|
130
143
|
- **`"cascade"`** (default): Tier 1 → Tier 2 → Tier 3, with Tier 3 invoked only when the Tier 2 effective score falls in the gray band (default `[0.3, 0.85)`). The Tier 3 verdict overrides Tier 2 on the escalated chunk — a `block` forces a block, an `allow` rescues it. Outside the band defender skips the round trip.
|
|
131
144
|
- **`"tier3_only"`**: skip Tier 2; the block/allow decision is the Tier 3 verdict alone. Tier 1 still runs detection; the returned `sanitized` payload is the original content.
|
|
132
145
|
|
|
133
|
-
|
|
146
|
+
```python
|
|
147
|
+
import os
|
|
148
|
+
|
|
149
|
+
from stackone_defender import create_openai_compatible_tier3_provider, create_prompt_defense
|
|
150
|
+
|
|
151
|
+
judge = create_openai_compatible_tier3_provider(
|
|
152
|
+
base_url="https://api.openai.com/v1",
|
|
153
|
+
api_key=os.environ["OPENAI_API_KEY"],
|
|
154
|
+
model="gpt-4.1-mini",
|
|
155
|
+
)
|
|
156
|
+
defense = create_prompt_defense(
|
|
157
|
+
block_high_risk=True,
|
|
158
|
+
enable_tier3=True,
|
|
159
|
+
defender_mode="tier3_only",
|
|
160
|
+
tier3={"provider": judge, "timeout_ms": 10_000, "on_error": "block"},
|
|
161
|
+
)
|
|
162
|
+
result = await defense.defend_tool_result_async(
|
|
163
|
+
tool_output, "gmail_get_message", context={"user_goal": "Summarize my unread mail"}
|
|
164
|
+
)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Around any other LLM, `complete` gets a request with `messages` (system + user), `deadline` and `json_schema`, and returns the reply text (sync or async):
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
import time
|
|
171
|
+
|
|
172
|
+
from stackone_defender import create_llm_tier3_provider
|
|
173
|
+
|
|
174
|
+
async def complete(req):
|
|
175
|
+
# req.deadline is absolute (time.time() seconds), or None when tier3.timeout_ms isn't set.
|
|
176
|
+
timeout = req.deadline - time.time() if req.deadline else 30
|
|
177
|
+
reply = await my_llm.chat(req.messages, timeout=timeout)
|
|
178
|
+
return reply.text # or {"text": ..., "refused": True} / {"text": ..., "score": p_block}
|
|
179
|
+
|
|
180
|
+
judge = create_llm_tier3_provider(complete=complete)
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
The default prompt (`DEFAULT_TIER3_PROMPT`, version `TIER3_PROMPT_VERSION`) wraps the tool output, user goal and tool description in random-id markers and asks for `{"decision": "block" | "allow", "reason": "..."}`. Pass `prompt=Tier3PromptTemplate(system=..., user=...)` (the `user` part must contain `{{content}}` once; also `{{toolName}}`, `{{userGoal}}`, `{{toolDescription}}`) or a builder function that gets a `PromptInput`. A refusal, a content-filter hit, or a reply that isn't exactly one JSON verdict **blocks** (`result.tier3.refused`), because attacker text can cause those on purpose. `result.tier3.reason` is model output that can quote the attacker: log it, but never feed it back to an agent.
|
|
184
|
+
|
|
185
|
+
A hand-written provider, registered once at startup:
|
|
134
186
|
|
|
135
187
|
```python
|
|
136
188
|
from typing import Any
|
|
@@ -140,8 +192,9 @@ from stackone_defender.types import Tier3Verdict
|
|
|
140
192
|
|
|
141
193
|
class MyProvider:
|
|
142
194
|
def classify(self, text: str, *, ctx: dict[str, Any] | None = None) -> Tier3Verdict:
|
|
143
|
-
# Call your LLM endpoint here (sync or awaitable). ctx
|
|
144
|
-
|
|
195
|
+
# Call your LLM endpoint here (sync or awaitable). ctx has tool_name, mode, chunk,
|
|
196
|
+
# user_goal / tool_description (untrusted), and deadline when timeout_ms is set.
|
|
197
|
+
verdict = call_my_llm(text, tool_name=(ctx or {}).get("tool_name"))
|
|
145
198
|
return Tier3Verdict(decision="block" if verdict.block else "allow", score=verdict.p_block)
|
|
146
199
|
|
|
147
200
|
set_default_tier3_provider(MyProvider())
|
|
@@ -176,7 +229,24 @@ A failed chunk is skipped (fail open), and `result.tier3.errored` and `coverage_
|
|
|
176
229
|
|
|
177
230
|
**Choosing the operating point (`block_threshold`).** By default the model's `decision` word is authoritative — but that word is its argmax, an implicit 0.5 cut that moves on its own whenever the model is retrained. Set `tier3.block_threshold` to decide on `verdict.score` (P(block)) instead: the cut becomes an explicit config value — raise it to trade recall for fewer false positives, lower it for the reverse; `0.5` reproduces argmax. It requires a provider that reports `score` as P(block) (not "confidence in whichever decision I made" — those invert on allows). If `score` is missing or outside `[0, 1]` the verdict's `decision` is used instead and defender warns once, so a provider that cannot report a score degrades to the default rather than failing.
|
|
178
231
|
|
|
179
|
-
**
|
|
232
|
+
**Policy options** (all in `tier3={...}`; camelCase keys work too):
|
|
233
|
+
|
|
234
|
+
| Option | Default | What it does |
|
|
235
|
+
|---|---|---|
|
|
236
|
+
| `allow_override` | `True` | Cascade: whether a Tier-3 `allow` can undo a Tier-2 block. `False`: Tier 3 only adds blocks. `{"max_tier2_score": x}`: only when Tier 2's undamped score is below `x`. A general-purpose LLM can be talked into `allow`, so prefer `False` or a cap (defender warns once otherwise). |
|
|
237
|
+
| `on_error` | cascade `"tier2"`, tier3_only `"allow"` | What a failure (error, timeout, invalid verdict) does. `"tier2"` keeps Tier 2's verdict. `"allow"` treats it as an allow. `"block"` blocks: in cascade when Tier 2 rated the payload medium or higher; in tier3_only when any chunk failed (and `on_oversize` then defaults to `"scan_anyway"`). A missing provider fails closed too. |
|
|
238
|
+
| `decide` | none | `decide(Tier3DecisionInput) -> "block" \| "allow" \| "default"`, called once per call after Tier 3 ran or failed. Replaces `allow_override` and `on_error` for that call. Gets metadata only (never content). A raise or bad return means `"default"`. |
|
|
239
|
+
| `timeout_ms` | none | Per provider call, from when it gets a concurrency slot. An async provider is cancelled at the deadline (`ctx["deadline"]`); a sync one can't be interrupted. |
|
|
240
|
+
| `max_concurrency` | unbounded | Max in-flight provider calls per instance (per event loop). Set `timeout_ms` with it. |
|
|
241
|
+
| `stop_on_first_block` | `False` | tier3_only: cancel the remaining chunk calls once one blocks. |
|
|
242
|
+
| `on_event` | none | Called once per provider call with a `Tier3Event` (kind, mode, chunk, latency, usage, redacted error code). No content. Exceptions are ignored. |
|
|
243
|
+
| `include_raw` | `True` | Keep `raw` on `result.tier3`. `raw` can quote tool content. |
|
|
244
|
+
|
|
245
|
+
**Result fields.** `result.tier3` is a `Tier3Verdict` (or a `Tier3Skip` when no verdict was obtained) and `None` when Tier 3 didn't run. Besides `decision`, `score`, `reason`, `refused` and `prompt_version`, defender sets: `errored` (a real failure), `note` (why the outcome isn't the verdict's alone), `override_applied` / `override_suppressed` (cascade), and `decided_by` (`"policy"` or `"options"` when `decide` is set). Provider error text in `skip_reason`, `note` and events is redacted (keys, tokens, URL paths) and capped.
|
|
246
|
+
|
|
247
|
+
**Cancellation.** Cancelling the task that awaits `defend_tool_result_async` cancels in-flight provider calls and raises `CancelledError`.
|
|
248
|
+
|
|
249
|
+
**Fail-open by default.** Without `on_error="block"`, a provider error or timeout records a `skip_reason`; in cascade defender falls back to the Tier 2 decision, in `tier3_only` it allows. `enable_tier3=True` with no provider (and no `on_error="block"`) falls back to Tier 1 + Tier 2 and logs one warning per instance, so Tier 3 misconfiguration never silently disables defense.
|
|
180
250
|
|
|
181
251
|
### `allowed` vs `risk_level`
|
|
182
252
|
|
|
@@ -213,7 +283,7 @@ defense = create_prompt_defense(
|
|
|
213
283
|
)
|
|
214
284
|
```
|
|
215
285
|
|
|
216
|
-
### `defense.defend_tool_result(value, tool_name)`
|
|
286
|
+
### `defense.defend_tool_result(value, tool_name, *, context=None)`
|
|
217
287
|
|
|
218
288
|
Runs Tier 1 sanitization on risky fields of the **original** payload, then Tier 2 on strings from the SFE-filtered view when SFE is on (otherwise the full value). Optional `tier2_fields` restricts Tier 2 extraction to specific keys; omit it to classify **all** strings (matches `@stackone/defender` 0.6.3). **Synchronous** — no `await`.
|
|
219
289
|
|
|
@@ -332,7 +402,7 @@ uv run pytest
|
|
|
332
402
|
This package mirrors [`@stackone/defender`](https://github.com/StackOneHQ/defender). `tests/conformance/` is a pinned copy of its shared conformance fixtures: input → expected-output cases for Tier-3 serialization, prompt rendering, verdict parsing, error redaction and policy decisions. `tests/test_conformance.py` runs them against this package.
|
|
333
403
|
|
|
334
404
|
- `scripts/sync_conformance.sh <ref>` copies the fixtures at a TS tag or commit and records it in `tests/conformance/VERSION`. CI checks the copy matches (`--check`).
|
|
335
|
-
-
|
|
405
|
+
- Every case passes. A new TS case this package can't match yet goes in `tests/conformance_known_failures.txt`, where it runs as a strict xfail; remove its line once a port makes it pass.
|
|
336
406
|
- Port contract for the runner: a rejected prompt template and an unusable verdict reply raise `ValueError`; optional result fields are `None` when unset (not `False`).
|
|
337
407
|
|
|
338
408
|
Traps when porting from TypeScript (the fixtures pin TS behavior):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "stackone-defender"
|
|
3
|
-
version = "0.9.
|
|
3
|
+
version = "0.9.1"
|
|
4
4
|
description = "Indirect prompt injection defense for AI agents using tool calls"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -28,6 +28,8 @@ onnx = ["onnxruntime>=1.16.0", "tokenizers>=0.15.0", "numpy>=1.24.0"]
|
|
|
28
28
|
# fasttext-ng provides the `fasttext` module (maintained bindings; supports 3.13).
|
|
29
29
|
# Pulls numpy>=2.3; SFE still fail-opens when import/load fails.
|
|
30
30
|
sfe = ["fasttext-ng>=0.9.3"]
|
|
31
|
+
# create_openai_compatible_tier3_provider (Tier 3 over any OpenAI-compatible endpoint).
|
|
32
|
+
llm = ["httpx>=0.27"]
|
|
31
33
|
|
|
32
34
|
[dependency-groups]
|
|
33
35
|
dev = [
|
|
@@ -38,6 +40,7 @@ dev = [
|
|
|
38
40
|
"tokenizers>=0.15.0",
|
|
39
41
|
"numpy>=1.24.0",
|
|
40
42
|
"fasttext-ng>=0.9.3",
|
|
43
|
+
"httpx>=0.27",
|
|
41
44
|
]
|
|
42
45
|
|
|
43
46
|
[build-system]
|
|
@@ -12,6 +12,18 @@ Usage:
|
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
14
|
from .classifiers.onnx_classifier import get_default_model_path
|
|
15
|
+
from .classifiers.tier3_llm_provider import (
|
|
16
|
+
DEFAULT_TIER3_PROMPT,
|
|
17
|
+
TIER3_PROMPT_VERSION,
|
|
18
|
+
VERDICT_JSON_SCHEMA,
|
|
19
|
+
LlmCompletionRequest,
|
|
20
|
+
PromptInput,
|
|
21
|
+
Tier3PromptTemplate,
|
|
22
|
+
build_tier3_messages,
|
|
23
|
+
create_llm_tier3_provider,
|
|
24
|
+
parse_verdict_json,
|
|
25
|
+
)
|
|
26
|
+
from .classifiers.tier3_openai_compatible import create_openai_compatible_tier3_provider
|
|
15
27
|
from .classifiers.tier3_orchestrator import get_default_tier3_provider, set_default_tier3_provider
|
|
16
28
|
from .core.prompt_defense import PromptDefense, create_prompt_defense
|
|
17
29
|
from .sfe.preprocess import (
|
|
@@ -36,26 +48,36 @@ from .types import (
|
|
|
36
48
|
from .utils.boundary import contains_boundary_patterns, generate_boundary_instructions
|
|
37
49
|
|
|
38
50
|
__all__ = [
|
|
51
|
+
"DEFAULT_TIER3_PROMPT",
|
|
52
|
+
"TIER3_PROMPT_VERSION",
|
|
53
|
+
"VERDICT_JSON_SCHEMA",
|
|
39
54
|
"DefenderMode",
|
|
40
55
|
"DefenseResult",
|
|
41
56
|
"DropDecision",
|
|
57
|
+
"LlmCompletionRequest",
|
|
42
58
|
"MultiheadConfig",
|
|
43
59
|
"PromptDefense",
|
|
60
|
+
"PromptInput",
|
|
44
61
|
"RiskLevel",
|
|
45
62
|
"SfePredictor",
|
|
46
63
|
"SfePreprocessResult",
|
|
47
64
|
"Tier1Result",
|
|
48
65
|
"Tier3ChunkSummary",
|
|
66
|
+
"Tier3PromptTemplate",
|
|
49
67
|
"Tier3Provider",
|
|
50
68
|
"Tier3TokenUsage",
|
|
51
69
|
"Tier3Verdict",
|
|
70
|
+
"build_tier3_messages",
|
|
52
71
|
"contains_boundary_patterns",
|
|
72
|
+
"create_llm_tier3_provider",
|
|
73
|
+
"create_openai_compatible_tier3_provider",
|
|
53
74
|
"create_prompt_defense",
|
|
54
75
|
"generate_boundary_instructions",
|
|
55
76
|
"get_default_model_path",
|
|
56
77
|
"get_default_predictor",
|
|
57
78
|
"get_default_sfe_model_path",
|
|
58
79
|
"get_default_tier3_provider",
|
|
80
|
+
"parse_verdict_json",
|
|
59
81
|
"set_default_tier3_provider",
|
|
60
82
|
"sfe_preprocess",
|
|
61
83
|
]
|