stackone-defender 0.8.2__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.8.2 → stackone_defender-0.9.1}/CHANGELOG.md +19 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/PKG-INFO +117 -10
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/README.md +114 -9
- stackone_defender-0.9.1/prof.out +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/pyproject.toml +4 -1
- stackone_defender-0.9.1/scripts/sync_conformance.sh +47 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/__init__.py +24 -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.8.2 → stackone_defender-0.9.1}/src/stackone_defender/core/prompt_defense.py +399 -170
- stackone_defender-0.9.1/src/stackone_defender/core/tier3_input.py +585 -0
- stackone_defender-0.9.1/src/stackone_defender/core/tier3_policy.py +490 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/types.py +108 -2
- 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/README.md +82 -0
- stackone_defender-0.9.1/tests/conformance/VERSION +1 -0
- stackone_defender-0.9.1/tests/conformance/tier3-parse.json +176 -0
- stackone_defender-0.9.1/tests/conformance/tier3-policy.json +1742 -0
- stackone_defender-0.9.1/tests/conformance/tier3-prompt.json +342 -0
- stackone_defender-0.9.1/tests/conformance/tier3-redact.json +242 -0
- stackone_defender-0.9.1/tests/conformance/tier3-serialize.json +3794 -0
- stackone_defender-0.9.1/tests/conformance_known_failures.txt +5 -0
- stackone_defender-0.9.1/tests/test_conformance.py +357 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/tests/test_onnx_classifier.py +3 -4
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/tests/test_tier3.py +287 -14
- stackone_defender-0.9.1/tests/test_tier3_policy.py +580 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/uv.lock +9 -3
- stackone_defender-0.8.2/.release-please-manifest.json +0 -1
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/.gitignore +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/.python-version +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/.release-please-config.json +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/LICENSE +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/classifiers/__init__.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/classifiers/onnx_classifier.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/classifiers/pattern_detector.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/classifiers/patterns.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/classifiers/tier2_classifier.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/classifiers/tier3_orchestrator.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/config.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/core/__init__.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/core/sentence_cleaner.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/core/tool_result_sanitizer.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/models/minilm-multihead-v5/classifier_config.json +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/models/minilm-multihead-v5/config.json +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/models/minilm-multihead-v5/model_quantized.onnx +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/models/minilm-multihead-v5/tokenizer.json +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/models/minilm-multihead-v5/tokenizer_config.json +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/py.typed +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/sanitizers/__init__.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/sanitizers/encoding_detector.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/sanitizers/leet_normalizer.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/sanitizers/normalizer.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/sanitizers/role_stripper.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/sfe/__init__.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/sfe/model.ftz +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/sfe/preprocess.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/utils/__init__.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/utils/boundary.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/utils/field_detection.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/src/stackone_defender/utils/structure.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/tests/__init__.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/tests/test_integration.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/tests/test_pattern_detector.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/tests/test_sanitizers.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/tests/test_sentence_cleaner.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/tests/test_sfe.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/tests/test_tier2_classifier.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.1}/tests/test_utils.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{".":"0.9.1"}
|
|
@@ -1,5 +1,24 @@
|
|
|
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
|
+
|
|
10
|
+
## [0.9.0](https://github.com/StackOneHQ/defender-py/compare/stackone-defender-v0.8.2...stackone-defender-v0.9.0) (2026-10-20)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* **ENG-3500:** record-oriented, chunked Tier-3 review in tier3_only ([#35](https://github.com/StackOneHQ/defender-py/issues/35)) ([072d7ed](https://github.com/StackOneHQ/defender-py/commit/072d7ed7f0474afa5ffe87a66920d279ad7fafdd))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Miscellaneous Chores
|
|
19
|
+
|
|
20
|
+
* **ENG-3500:** release 0.9.0 ([#37](https://github.com/StackOneHQ/defender-py/issues/37)) ([aa4a4e3](https://github.com/StackOneHQ/defender-py/commit/aa4a4e39fcb7a7adc6d43fbab8d7b84817613dbc))
|
|
21
|
+
|
|
3
22
|
## [0.8.2](https://github.com/StackOneHQ/defender-py/compare/stackone-defender-v0.7.4...stackone-defender-v0.8.2) (2026-08-19)
|
|
4
23
|
|
|
5
24
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: stackone-defender
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.1
|
|
4
4
|
Summary: Indirect prompt injection defense for AI agents using tool calls
|
|
5
5
|
Project-URL: Homepage, https://github.com/StackOneHQ/stackone-defender
|
|
6
6
|
Project-URL: Repository, https://github.com/StackOneHQ/stackone-defender
|
|
@@ -16,6 +16,8 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
16
16
|
Classifier: Topic :: Security
|
|
17
17
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
18
|
Requires-Python: >=3.11
|
|
19
|
+
Provides-Extra: llm
|
|
20
|
+
Requires-Dist: httpx>=0.27; extra == 'llm'
|
|
19
21
|
Provides-Extra: onnx
|
|
20
22
|
Requires-Dist: numpy>=1.24.0; extra == 'onnx'
|
|
21
23
|
Requires-Dist: onnxruntime>=1.16.0; extra == 'onnx'
|
|
@@ -83,6 +85,15 @@ pip install stackone-defender[sfe]
|
|
|
83
85
|
# or: uv add "stackone-defender[sfe]"
|
|
84
86
|
```
|
|
85
87
|
|
|
88
|
+
**Tier 3 over an OpenAI-compatible endpoint (optional)** — add extras:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
pip install stackone-defender[llm]
|
|
92
|
+
# or: uv add "stackone-defender[llm]"
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The `[llm]` extra installs [`httpx`](https://www.python-httpx.org/) for `create_openai_compatible_tier3_provider`. `create_llm_tier3_provider` needs no extra.
|
|
96
|
+
|
|
86
97
|
The `[sfe]` extra installs [`fasttext-ng`](https://pypi.org/project/fasttext-ng/) (provides the `fasttext` module). It requires **NumPy 2.3+**. PyPI may ship a wheel only for some platforms; otherwise pip/uv builds from source (needs a C++ toolchain).
|
|
87
98
|
|
|
88
99
|
## Quick start
|
|
@@ -148,15 +159,58 @@ Packed-chunk MiniLM classifier (int8 ONNX ~22 MB, bundled):
|
|
|
148
159
|
| jayavibhav (adversarial) | 0.9717 | ~1k |
|
|
149
160
|
| **Average** | **0.9079** | ~25k |
|
|
150
161
|
|
|
151
|
-
### Optional Tier 3 — LLM adjudication (
|
|
162
|
+
### Optional Tier 3 — LLM adjudication (bring your own LLM)
|
|
152
163
|
|
|
153
|
-
Authoritative LLM-based classification for the cases Tier 2 finds ambiguous.
|
|
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:
|
|
165
|
+
|
|
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())
|
|
@@ -179,15 +234,47 @@ defense = create_prompt_defense(
|
|
|
179
234
|
tier3={
|
|
180
235
|
"provider": MyProvider(), # optional: overrides the registry for this instance
|
|
181
236
|
"escalation_band": {"lower": 0.3, "upper": 0.85}, # cascade gray band; [lower, upper), defaults shown
|
|
182
|
-
"max_text_length": 10000, #
|
|
237
|
+
"max_text_length": 10000, # max chars per provider call
|
|
238
|
+
"max_chunks": 5, # tier3_only: chunks reviewed per tool result
|
|
239
|
+
"on_oversize": "skip", # tier3_only: "skip" | "block" | "scan_anyway"
|
|
183
240
|
"block_threshold": 0.622, # optional; decide on score, not the model's word
|
|
184
241
|
},
|
|
185
242
|
)
|
|
186
243
|
```
|
|
187
244
|
|
|
245
|
+
**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
|
+
|
|
247
|
+
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`.
|
|
248
|
+
|
|
249
|
+
**Oversize (`on_oversize`).** Up to about `max_chunks` chunks are reviewed per tool result. Content beyond that is oversize, and `coverage_degraded` is `True`. Blocking below requires `block_high_risk=True`.
|
|
250
|
+
- `"skip"` (default): review the content that fits and allow the rest. A fitting injection still blocks. This favors availability, so set `"block"` or `"scan_anyway"` to fail closed.
|
|
251
|
+
- `"block"`: block without calling the provider. `tier3_chunk_summary` is `None` and `result.tier3.skip_reason` says why.
|
|
252
|
+
- `"scan_anyway"`: review what fits, then block on the unreviewed rest unless a chunk already blocked.
|
|
253
|
+
|
|
254
|
+
In `"skip"` and `"scan_anyway"`, `tier3_chunk_summary.oversize` is `True`.
|
|
255
|
+
|
|
256
|
+
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
|
+
|
|
188
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.
|
|
189
259
|
|
|
190
|
-
**
|
|
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.
|
|
191
278
|
|
|
192
279
|
### `allowed` vs `risk_level`
|
|
193
280
|
|
|
@@ -224,7 +311,7 @@ defense = create_prompt_defense(
|
|
|
224
311
|
)
|
|
225
312
|
```
|
|
226
313
|
|
|
227
|
-
### `defense.defend_tool_result(value, tool_name)`
|
|
314
|
+
### `defense.defend_tool_result(value, tool_name, *, context=None)`
|
|
228
315
|
|
|
229
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`.
|
|
230
317
|
|
|
@@ -257,7 +344,8 @@ class DefenseResult:
|
|
|
257
344
|
cold_load: bool | None = None
|
|
258
345
|
# Operational signals
|
|
259
346
|
tier2_available: bool | None = None # False when Tier 2 enabled but failed to load
|
|
260
|
-
coverage_degraded: bool | None = None # True when Tier 1 detection
|
|
347
|
+
coverage_degraded: bool | None = None # True when Tier 1 detection or the Tier 3 review didn't cover everything
|
|
348
|
+
tier3_chunk_summary: Tier3ChunkSummary | None = None # tier3_only: chunks, blocked, blocking_chunk, oversize
|
|
261
349
|
```
|
|
262
350
|
|
|
263
351
|
### `defense.defend_tool_results(items)`
|
|
@@ -337,6 +425,25 @@ uv sync --group dev
|
|
|
337
425
|
uv run pytest
|
|
338
426
|
```
|
|
339
427
|
|
|
428
|
+
### Parity with the TypeScript package
|
|
429
|
+
|
|
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.
|
|
431
|
+
|
|
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`).
|
|
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.
|
|
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`).
|
|
435
|
+
|
|
436
|
+
Traps when porting from TypeScript (the fixtures pin TS behavior):
|
|
437
|
+
|
|
438
|
+
- Lengths and slicing are in UTF-16 code units in TS, but `len()` counts code points.
|
|
439
|
+
- TS `Object.entries` puts integer-like keys first. `json.loads` keeps insertion order.
|
|
440
|
+
- Number rendering differs: `String(1.0)` is `"1"` but `str(1.0)` is `"1.0"`. Likewise `-0`, `1e-7` and `1e16` render differently in the two languages.
|
|
441
|
+
- `core/tier3_input.py` handles the three traps above for Tier-3 input. It works on a UTF-16 view of each string, orders keys like `Object.entries`, and renders numbers like `String(n)`. One deliberate difference: Python ints below 1e21 print exactly, where JS would round past 2**53.
|
|
442
|
+
- Python `re` has no `\p{Cf}`, and `unicodedata` versions differ across Python releases.
|
|
443
|
+
- `str.splitlines()` also splits on `\x0b`, `\x0c` and `\x1c`, but TS splits only on `\r`, `\n`, `\u2028`, `\u2029` and `\u0085`.
|
|
444
|
+
- JS `trim()` removes a BOM, but `str.strip()` doesn't.
|
|
445
|
+
- `json.loads` accepts `NaN`, but JS `JSON.parse` rejects it. Use `parse_constant` to reject it.
|
|
446
|
+
|
|
340
447
|
## License
|
|
341
448
|
|
|
342
449
|
Apache-2.0 — see [LICENSE](./LICENSE).
|
|
@@ -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)
|
|
126
135
|
|
|
127
|
-
Authoritative LLM-based classification for the cases Tier 2 finds ambiguous.
|
|
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:
|
|
137
|
+
|
|
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())
|
|
@@ -153,15 +206,47 @@ defense = create_prompt_defense(
|
|
|
153
206
|
tier3={
|
|
154
207
|
"provider": MyProvider(), # optional: overrides the registry for this instance
|
|
155
208
|
"escalation_band": {"lower": 0.3, "upper": 0.85}, # cascade gray band; [lower, upper), defaults shown
|
|
156
|
-
"max_text_length": 10000, #
|
|
209
|
+
"max_text_length": 10000, # max chars per provider call
|
|
210
|
+
"max_chunks": 5, # tier3_only: chunks reviewed per tool result
|
|
211
|
+
"on_oversize": "skip", # tier3_only: "skip" | "block" | "scan_anyway"
|
|
157
212
|
"block_threshold": 0.622, # optional; decide on score, not the model's word
|
|
158
213
|
},
|
|
159
214
|
)
|
|
160
215
|
```
|
|
161
216
|
|
|
217
|
+
**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)`.
|
|
218
|
+
|
|
219
|
+
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`.
|
|
220
|
+
|
|
221
|
+
**Oversize (`on_oversize`).** Up to about `max_chunks` chunks are reviewed per tool result. Content beyond that is oversize, and `coverage_degraded` is `True`. Blocking below requires `block_high_risk=True`.
|
|
222
|
+
- `"skip"` (default): review the content that fits and allow the rest. A fitting injection still blocks. This favors availability, so set `"block"` or `"scan_anyway"` to fail closed.
|
|
223
|
+
- `"block"`: block without calling the provider. `tier3_chunk_summary` is `None` and `result.tier3.skip_reason` says why.
|
|
224
|
+
- `"scan_anyway"`: review what fits, then block on the unreviewed rest unless a chunk already blocked.
|
|
225
|
+
|
|
226
|
+
In `"skip"` and `"scan_anyway"`, `tier3_chunk_summary.oversize` is `True`.
|
|
227
|
+
|
|
228
|
+
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.
|
|
229
|
+
|
|
162
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.
|
|
163
231
|
|
|
164
|
-
**
|
|
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.
|
|
165
250
|
|
|
166
251
|
### `allowed` vs `risk_level`
|
|
167
252
|
|
|
@@ -198,7 +283,7 @@ defense = create_prompt_defense(
|
|
|
198
283
|
)
|
|
199
284
|
```
|
|
200
285
|
|
|
201
|
-
### `defense.defend_tool_result(value, tool_name)`
|
|
286
|
+
### `defense.defend_tool_result(value, tool_name, *, context=None)`
|
|
202
287
|
|
|
203
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`.
|
|
204
289
|
|
|
@@ -231,7 +316,8 @@ class DefenseResult:
|
|
|
231
316
|
cold_load: bool | None = None
|
|
232
317
|
# Operational signals
|
|
233
318
|
tier2_available: bool | None = None # False when Tier 2 enabled but failed to load
|
|
234
|
-
coverage_degraded: bool | None = None # True when Tier 1 detection
|
|
319
|
+
coverage_degraded: bool | None = None # True when Tier 1 detection or the Tier 3 review didn't cover everything
|
|
320
|
+
tier3_chunk_summary: Tier3ChunkSummary | None = None # tier3_only: chunks, blocked, blocking_chunk, oversize
|
|
235
321
|
```
|
|
236
322
|
|
|
237
323
|
### `defense.defend_tool_results(items)`
|
|
@@ -311,6 +397,25 @@ uv sync --group dev
|
|
|
311
397
|
uv run pytest
|
|
312
398
|
```
|
|
313
399
|
|
|
400
|
+
### Parity with the TypeScript package
|
|
401
|
+
|
|
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.
|
|
403
|
+
|
|
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`).
|
|
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.
|
|
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`).
|
|
407
|
+
|
|
408
|
+
Traps when porting from TypeScript (the fixtures pin TS behavior):
|
|
409
|
+
|
|
410
|
+
- Lengths and slicing are in UTF-16 code units in TS, but `len()` counts code points.
|
|
411
|
+
- TS `Object.entries` puts integer-like keys first. `json.loads` keeps insertion order.
|
|
412
|
+
- Number rendering differs: `String(1.0)` is `"1"` but `str(1.0)` is `"1.0"`. Likewise `-0`, `1e-7` and `1e16` render differently in the two languages.
|
|
413
|
+
- `core/tier3_input.py` handles the three traps above for Tier-3 input. It works on a UTF-16 view of each string, orders keys like `Object.entries`, and renders numbers like `String(n)`. One deliberate difference: Python ints below 1e21 print exactly, where JS would round past 2**53.
|
|
414
|
+
- Python `re` has no `\p{Cf}`, and `unicodedata` versions differ across Python releases.
|
|
415
|
+
- `str.splitlines()` also splits on `\x0b`, `\x0c` and `\x1c`, but TS splits only on `\r`, `\n`, `\u2028`, `\u2029` and `\u0085`.
|
|
416
|
+
- JS `trim()` removes a BOM, but `str.strip()` doesn't.
|
|
417
|
+
- `json.loads` accepts `NaN`, but JS `JSON.parse` rejects it. Use `parse_constant` to reject it.
|
|
418
|
+
|
|
314
419
|
## License
|
|
315
420
|
|
|
316
421
|
Apache-2.0 — see [LICENSE](./LICENSE).
|
|
Binary file
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "stackone-defender"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.9.1"
|
|
4
4
|
description = "Indirect prompt injection defense for AI agents using tool calls"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -28,6 +28,8 @@ onnx = ["onnxruntime>=1.16.0", "tokenizers>=0.15.0", "numpy>=1.24.0"]
|
|
|
28
28
|
# fasttext-ng provides the `fasttext` module (maintained bindings; supports 3.13).
|
|
29
29
|
# Pulls numpy>=2.3; SFE still fail-opens when import/load fails.
|
|
30
30
|
sfe = ["fasttext-ng>=0.9.3"]
|
|
31
|
+
# create_openai_compatible_tier3_provider (Tier 3 over any OpenAI-compatible endpoint).
|
|
32
|
+
llm = ["httpx>=0.27"]
|
|
31
33
|
|
|
32
34
|
[dependency-groups]
|
|
33
35
|
dev = [
|
|
@@ -38,6 +40,7 @@ dev = [
|
|
|
38
40
|
"tokenizers>=0.15.0",
|
|
39
41
|
"numpy>=1.24.0",
|
|
40
42
|
"fasttext-ng>=0.9.3",
|
|
43
|
+
"httpx>=0.27",
|
|
41
44
|
]
|
|
42
45
|
|
|
43
46
|
[build-system]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Sync the shared conformance fixtures from the TypeScript package (StackOneHQ/defender).
|
|
3
|
+
#
|
|
4
|
+
# scripts/sync_conformance.sh <ref> copy conformance/ at <ref> (a tag like defender-v0.9.2, or a commit SHA)
|
|
5
|
+
# into tests/conformance/ and record <ref> in tests/conformance/VERSION
|
|
6
|
+
# scripts/sync_conformance.sh --check verify tests/conformance/ matches the ref in VERSION (used in CI)
|
|
7
|
+
set -euo pipefail
|
|
8
|
+
|
|
9
|
+
REPO="StackOneHQ/defender"
|
|
10
|
+
DEST="tests/conformance"
|
|
11
|
+
|
|
12
|
+
# A full commit SHA or a release tag only: a branch would float, and anything else may not be a TS ref.
|
|
13
|
+
valid_ref() {
|
|
14
|
+
[[ "$1" =~ ^[0-9a-f]{40}$ || "$1" =~ ^defender-v[0-9]+\.[0-9]+\.[0-9]+$ ]]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
fetch() {
|
|
18
|
+
local ref="$1" out="$2"
|
|
19
|
+
valid_ref "$ref" || { echo "invalid ref '${ref}': use a full 40-char commit SHA or a defender-vX.Y.Z tag" >&2; exit 2; }
|
|
20
|
+
# Whole archive (small: LFS models come through as pointers); portable across GNU and BSD tar.
|
|
21
|
+
curl -fsSL --retry 3 --retry-delay 2 --max-time 60 "https://codeload.github.com/${REPO}/tar.gz/${ref}" | tar -xz -C "$out" --strip-components=1
|
|
22
|
+
[ -d "$out/conformance" ] || { echo "no conformance/ folder at ${REPO}@${ref}" >&2; exit 1; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
cd "$(dirname "$0")/.."
|
|
26
|
+
tmp="$(mktemp -d)"
|
|
27
|
+
trap 'rm -rf "$tmp"' EXIT
|
|
28
|
+
|
|
29
|
+
if [ "${1:-}" = "--check" ]; then
|
|
30
|
+
ref="$(tr -d '[:space:]' < "$DEST/VERSION")"
|
|
31
|
+
fetch "$ref" "$tmp"
|
|
32
|
+
if diff -r --exclude VERSION "$tmp/conformance" "$DEST"; then
|
|
33
|
+
echo "tests/conformance matches ${REPO}@${ref}"
|
|
34
|
+
else
|
|
35
|
+
echo "tests/conformance differs from ${REPO}@${ref}: re-run scripts/sync_conformance.sh ${ref}" >&2
|
|
36
|
+
exit 1
|
|
37
|
+
fi
|
|
38
|
+
exit 0
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
ref="${1:?usage: $0 <ref> | --check}"
|
|
42
|
+
fetch "$ref" "$tmp"
|
|
43
|
+
rm -rf "$DEST"
|
|
44
|
+
mkdir -p "$DEST"
|
|
45
|
+
cp -R "$tmp/conformance/." "$DEST/"
|
|
46
|
+
echo "$ref" > "$DEST/VERSION"
|
|
47
|
+
echo "synced ${REPO}@${ref} → ${DEST}"
|
|
@@ -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 (
|
|
@@ -28,6 +40,7 @@ from .types import (
|
|
|
28
40
|
MultiheadConfig,
|
|
29
41
|
RiskLevel,
|
|
30
42
|
Tier1Result,
|
|
43
|
+
Tier3ChunkSummary,
|
|
31
44
|
Tier3Provider,
|
|
32
45
|
Tier3TokenUsage,
|
|
33
46
|
Tier3Verdict,
|
|
@@ -35,25 +48,36 @@ from .types import (
|
|
|
35
48
|
from .utils.boundary import contains_boundary_patterns, generate_boundary_instructions
|
|
36
49
|
|
|
37
50
|
__all__ = [
|
|
51
|
+
"DEFAULT_TIER3_PROMPT",
|
|
52
|
+
"TIER3_PROMPT_VERSION",
|
|
53
|
+
"VERDICT_JSON_SCHEMA",
|
|
38
54
|
"DefenderMode",
|
|
39
55
|
"DefenseResult",
|
|
40
56
|
"DropDecision",
|
|
57
|
+
"LlmCompletionRequest",
|
|
41
58
|
"MultiheadConfig",
|
|
42
59
|
"PromptDefense",
|
|
60
|
+
"PromptInput",
|
|
43
61
|
"RiskLevel",
|
|
44
62
|
"SfePredictor",
|
|
45
63
|
"SfePreprocessResult",
|
|
46
64
|
"Tier1Result",
|
|
65
|
+
"Tier3ChunkSummary",
|
|
66
|
+
"Tier3PromptTemplate",
|
|
47
67
|
"Tier3Provider",
|
|
48
68
|
"Tier3TokenUsage",
|
|
49
69
|
"Tier3Verdict",
|
|
70
|
+
"build_tier3_messages",
|
|
50
71
|
"contains_boundary_patterns",
|
|
72
|
+
"create_llm_tier3_provider",
|
|
73
|
+
"create_openai_compatible_tier3_provider",
|
|
51
74
|
"create_prompt_defense",
|
|
52
75
|
"generate_boundary_instructions",
|
|
53
76
|
"get_default_model_path",
|
|
54
77
|
"get_default_predictor",
|
|
55
78
|
"get_default_sfe_model_path",
|
|
56
79
|
"get_default_tier3_provider",
|
|
80
|
+
"parse_verdict_json",
|
|
57
81
|
"set_default_tier3_provider",
|
|
58
82
|
"sfe_preprocess",
|
|
59
83
|
]
|