stackone-defender 0.9.2__tar.gz → 0.9.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. stackone_defender-0.9.3/.release-please-manifest.json +1 -0
  2. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/CHANGELOG.md +7 -0
  3. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/PKG-INFO +16 -13
  4. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/README.md +15 -12
  5. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/pyproject.toml +1 -1
  6. stackone_defender-0.9.3/src/stackone_defender/models/minilm-multihead-v5/classifier_config.json +75 -0
  7. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/src/stackone_defender/models/minilm-multihead-v5/model_quantized.onnx +0 -0
  8. stackone_defender-0.9.3/tests/conformance/VERSION +1 -0
  9. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/tests/conformance/tier3-policy.json +31 -0
  10. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/tests/conformance/tier3-redact.json +50 -0
  11. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/tests/test_onnx_classifier.py +4 -2
  12. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/tests/test_tier2_classifier.py +7 -6
  13. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/uv.lock +7 -7
  14. stackone_defender-0.9.2/.release-please-manifest.json +0 -1
  15. stackone_defender-0.9.2/src/stackone_defender/models/minilm-multihead-v5/classifier_config.json +0 -47
  16. stackone_defender-0.9.2/tests/conformance/VERSION +0 -1
  17. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/.gitattributes +0 -0
  18. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/.gitignore +0 -0
  19. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/.python-version +0 -0
  20. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/.release-please-config.json +0 -0
  21. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/LICENSE +0 -0
  22. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/hatch_build.py +0 -0
  23. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/prof.out +0 -0
  24. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/scripts/sync_conformance.sh +0 -0
  25. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/src/stackone_defender/__init__.py +0 -0
  26. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/src/stackone_defender/classifiers/__init__.py +0 -0
  27. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/src/stackone_defender/classifiers/onnx_classifier.py +0 -0
  28. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/src/stackone_defender/classifiers/pattern_detector.py +0 -0
  29. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/src/stackone_defender/classifiers/patterns.py +0 -0
  30. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/src/stackone_defender/classifiers/tier2_classifier.py +0 -0
  31. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/src/stackone_defender/classifiers/tier3_llm_provider.py +0 -0
  32. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/src/stackone_defender/classifiers/tier3_openai_compatible.py +0 -0
  33. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/src/stackone_defender/classifiers/tier3_orchestrator.py +0 -0
  34. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/src/stackone_defender/config.py +0 -0
  35. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/src/stackone_defender/core/__init__.py +0 -0
  36. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/src/stackone_defender/core/prompt_defense.py +0 -0
  37. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/src/stackone_defender/core/sentence_cleaner.py +0 -0
  38. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/src/stackone_defender/core/tier3_input.py +0 -0
  39. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/src/stackone_defender/core/tier3_policy.py +0 -0
  40. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/src/stackone_defender/core/tool_result_sanitizer.py +0 -0
  41. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/src/stackone_defender/models/minilm-multihead-v5/config.json +0 -0
  42. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/src/stackone_defender/models/minilm-multihead-v5/tokenizer.json +0 -0
  43. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/src/stackone_defender/models/minilm-multihead-v5/tokenizer_config.json +0 -0
  44. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/src/stackone_defender/py.typed +0 -0
  45. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/src/stackone_defender/sanitizers/__init__.py +0 -0
  46. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/src/stackone_defender/sanitizers/encoding_detector.py +0 -0
  47. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/src/stackone_defender/sanitizers/leet_normalizer.py +0 -0
  48. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/src/stackone_defender/sanitizers/normalizer.py +0 -0
  49. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/src/stackone_defender/sanitizers/role_stripper.py +0 -0
  50. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/src/stackone_defender/sfe/__init__.py +0 -0
  51. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/src/stackone_defender/sfe/model.ftz +0 -0
  52. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/src/stackone_defender/sfe/preprocess.py +0 -0
  53. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/src/stackone_defender/types.py +0 -0
  54. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/src/stackone_defender/utils/__init__.py +0 -0
  55. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/src/stackone_defender/utils/boundary.py +0 -0
  56. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/src/stackone_defender/utils/field_detection.py +0 -0
  57. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/src/stackone_defender/utils/lfs.py +0 -0
  58. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/src/stackone_defender/utils/redact.py +0 -0
  59. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/src/stackone_defender/utils/structure.py +0 -0
  60. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/src/stackone_defender/utils/text.py +0 -0
  61. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/tests/__init__.py +0 -0
  62. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/tests/conformance/README.md +0 -0
  63. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/tests/conformance/tier3-parse.json +0 -0
  64. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/tests/conformance/tier3-prompt.json +0 -0
  65. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/tests/conformance/tier3-serialize.json +0 -0
  66. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/tests/conformance_known_failures.txt +0 -0
  67. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/tests/test_conformance.py +0 -0
  68. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/tests/test_integration.py +0 -0
  69. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/tests/test_lfs.py +0 -0
  70. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/tests/test_pattern_detector.py +0 -0
  71. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/tests/test_sanitizers.py +0 -0
  72. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/tests/test_sentence_cleaner.py +0 -0
  73. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/tests/test_sfe.py +0 -0
  74. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/tests/test_tier3.py +0 -0
  75. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/tests/test_tier3_policy.py +0 -0
  76. {stackone_defender-0.9.2 → stackone_defender-0.9.3}/tests/test_utils.py +0 -0
@@ -0,0 +1 @@
1
+ {".":"0.9.3"}
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.3](https://github.com/StackOneHQ/defender-py/compare/stackone-defender-v0.9.2...stackone-defender-v0.9.3) (2026-10-20)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **ENG-3282:** swap the Tier-2 model and recalibrate its threshold ([#44](https://github.com/StackOneHQ/defender-py/issues/44)) ([5fd8f87](https://github.com/StackOneHQ/defender-py/commit/5fd8f8748bcc72a42324b5fba09d90465b062135))
9
+
3
10
  ## [0.9.2](https://github.com/StackOneHQ/defender-py/compare/stackone-defender-v0.9.1...stackone-defender-v0.9.2) (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.2
3
+ Version: 0.9.3
4
4
  Summary: Indirect prompt injection defense for AI agents using tool calls
5
5
  Project-URL: Homepage, https://defender.stackone.com/?utm_source=pypi&utm_medium=listing&utm_campaign=homepage
6
6
  Project-URL: Repository, https://github.com/StackOneHQ/defender-py
@@ -49,7 +49,7 @@ Description-Content-Type: text/markdown
49
49
  <img src="https://img.shields.io/badge/model-22MB-047B43?style=flat-square" alt="Model size: 22MB" />
50
50
  <img src="https://img.shields.io/badge/latency-~10ms-047B43?style=flat-square" alt="Latency: ~10ms" />
51
51
  <img src="https://img.shields.io/badge/CPU--only-no%20GPU%20needed-047B43?style=flat-square" alt="CPU only" />
52
- <img src="https://img.shields.io/badge/F1%20Score-90.8%25-047B43?style=flat-square" alt="F1 Score: 90.8%" />
52
+ <img src="https://img.shields.io/badge/F1%20Score-86.5%25-047B43?style=flat-square" alt="F1 Score: 86.5%" />
53
53
  </p>
54
54
 
55
55
  </div>
@@ -142,8 +142,9 @@ Packed-chunk MiniLM classifier (int8 ONNX ~22 MB, bundled):
142
142
  - Split text into sentences, pack to model-sized chunks, score chunks in batched ONNX calls
143
143
  - Catches paraphrased or novel injections missed by regex
144
144
  - Uses chunked batch inference to bound memory on large payloads
145
- - The bundled model is multi-head. 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
+ - The bundled model is multi-head (variant `jbv2-mh-grid-agpjb2pinstr-s2` since 0.9.3; it still lives in the `minilm-multihead-v5` folder so existing paths keep working). Its auxiliary head flags meta-discussion and documentation that quotes injection text.
146
+ - The bundled model carries calibrated defaults in its `classifier_config.json` (`high_risk_threshold` 0.52 at temperature 2.41). They override library defaults when the model loads. Thresholds are model-specific: 0.64 was right for the previous model, not this one.
147
+ - Multi-head mode is off by default; Tier 2 then rates a chunk high at `high_risk_threshold` (blocking also needs `block_high_risk=True`). To turn it on, pass a preset as `tier2_config={"multihead": MultiheadConfig(main_threshold=..., aux_threshold=...)}`: a chunk then blocks when its main score >= `main_threshold` and its aux score < `aux_threshold`, and the result reports `tier2_aux_score` and `tier2_multihead_blocked`. Presets tuned for this model: High 0.77/0.325, Medium 0.88/0.325, Low 0.94/0.30. They're also in `classifier_config.json` under `multihead_presets`, but defender doesn't load them for you.
147
148
 
148
149
  ### Optional SFE preprocessor
149
150
 
@@ -152,14 +153,16 @@ Packed-chunk MiniLM classifier (int8 ONNX ~22 MB, bundled):
152
153
  - **Tier 2** extracts strings from the SFE-filtered tree; `fields_dropped` lists paths omitted from that extraction (not removed from `sanitized`)
153
154
  - Fails open if the runtime/model is unavailable: payload continues unfiltered
154
155
 
155
- **Benchmarks** (F1 @ threshold 0.5):
156
+ **Benchmarks** (Tier 2 only, through the package pipeline at the default threshold 0.52; measured 2026-10-20):
156
157
 
157
- | Benchmark | F1 | Samples |
158
- |-----------|-----|--------|
159
- | Qualifire (in-distribution) | 0.8686 | ~1.5k |
160
- | xxz224 (out-of-distribution) | 0.8834 | ~22.5k |
161
- | jayavibhav (adversarial) | 0.9717 | ~1k |
162
- | **Average** | **0.9079** | ~25k |
158
+ | Benchmark | F1 | Precision | Recall | Samples |
159
+ |-----------|-----|-----------|--------|---------|
160
+ | Qualifire (held-out test split; the dataset's train split is used in training) | 0.828 | 0.857 | 0.800 | 751 |
161
+ | xxz224 (out-of-distribution) | 0.814 | 0.989 | 0.691 | ~22.5k |
162
+ | jayavibhav (unseen rows; other rows of this split are used in training) | 0.955 | 0.960 | 0.950 | ~60k |
163
+ | **Average** | **0.865** | | | |
164
+
165
+ The previous model scores 0.843 on the same sets and method. Earlier READMEs showed 0.908 from a different method (raw scores, best threshold picked per dataset), which isn't comparable. Latency is about 10 ms per call (p50 on a 2-vCPU container, short inputs); long inputs that split into several chunks take longer.
163
166
 
164
167
  ### Optional Tier 3 — LLM adjudication (bring your own LLM)
165
168
 
@@ -269,7 +272,7 @@ A failed chunk is skipped (fail open), and `result.tier3.errored` and `coverage_
269
272
  | Option | Default | What it does |
270
273
  |---|---|---|
271
274
  | `allow_override` | `True` | Cascade: whether a Tier-3 `allow` can undo a Tier-2 block. `False`: Tier 3 only adds blocks. `{"max_tier2_score": x}`: only when Tier 2's undamped score is below `x`. A general-purpose LLM can be talked into `allow`, so prefer `False` or a cap. Defender warns once when it's `True` with a provider from `create_llm_tier3_provider` or `create_openai_compatible_tier3_provider`. |
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"`. |
275
+ | `on_error` | cascade `"tier2"`, tier3_only `"allow"` | What a failure (error, timeout, invalid verdict) does. `"tier2"` keeps Tier 2's verdict. `"allow"` treats it as an allow. `"block"` blocks: in cascade when Tier 2 rated the payload medium or higher (with the bundled model Tier 2 rarely reports `medium`: its band is [0.5, 0.52)); in tier3_only when any chunk failed (and `on_oversize` then defaults to `"scan_anyway"`). A missing provider fails closed too. `"tier2"` isn't valid in tier3_only: defender warns and uses `"allow"`. |
273
276
  | `decide` | none | `decide(Tier3DecisionInput) -> "block" \| "allow" \| "default"`, called once per call after Tier 3 ran or failed. Replaces `allow_override` and `on_error` for that call. Gets metadata only (never content). Must be synchronous. A raise or bad return means `"default"`. It can't undo a Tier 1 detection, override `on_oversize`, or block when `block_high_risk=False`. |
274
277
  | `timeout_ms` | none | Per provider call, from when it gets a concurrency slot. An async provider is cancelled at the deadline (`ctx["deadline"]`); a sync one can't be interrupted. |
275
278
  | `max_concurrency` | unbounded | Max in-flight provider calls per instance, per event loop. Each sync `defend_tool_result` call runs its own loop, so the cap doesn't span threads that use the sync API. Set `timeout_ms` with it. |
@@ -314,7 +317,7 @@ defense = create_prompt_defense(
314
317
  annotate_boundary=False, # True: wrap risky strings with [UD-…] tags (npm: annotateBoundary)
315
318
  sanitize_content=True, # False: detect-and-gate; sanitized is the input verbatim
316
319
  tier2_fields=["subject", "body", "snippet"], # optional: scope Tier 2 to these JSON keys (default: all strings)
317
- tier2_config={"high_risk_threshold": 0.64}, # optional; defaults come from the bundled model's classifier_config.json
320
+ tier2_config={"high_risk_threshold": 0.52}, # optional; defaults come from the bundled model's classifier_config.json
318
321
  use_sfe=False, # True, or {"threshold": 0.5, "predictor": ...}: SFE preprocessing before Tier 2
319
322
  enable_tier3=False,
320
323
  defender_mode="cascade", # or "tier3_only"; ignored unless enable_tier3
@@ -19,7 +19,7 @@
19
19
  <img src="https://img.shields.io/badge/model-22MB-047B43?style=flat-square" alt="Model size: 22MB" />
20
20
  <img src="https://img.shields.io/badge/latency-~10ms-047B43?style=flat-square" alt="Latency: ~10ms" />
21
21
  <img src="https://img.shields.io/badge/CPU--only-no%20GPU%20needed-047B43?style=flat-square" alt="CPU only" />
22
- <img src="https://img.shields.io/badge/F1%20Score-90.8%25-047B43?style=flat-square" alt="F1 Score: 90.8%" />
22
+ <img src="https://img.shields.io/badge/F1%20Score-86.5%25-047B43?style=flat-square" alt="F1 Score: 86.5%" />
23
23
  </p>
24
24
 
25
25
  </div>
@@ -112,8 +112,9 @@ Packed-chunk MiniLM classifier (int8 ONNX ~22 MB, bundled):
112
112
  - Split text into sentences, pack to model-sized chunks, score chunks in batched ONNX calls
113
113
  - Catches paraphrased or novel injections missed by regex
114
114
  - Uses chunked batch inference to bound memory on large payloads
115
- - 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`.
116
- - 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.
115
+ - The bundled model is multi-head (variant `jbv2-mh-grid-agpjb2pinstr-s2` since 0.9.3; it still lives in the `minilm-multihead-v5` folder so existing paths keep working). Its auxiliary head flags meta-discussion and documentation that quotes injection text.
116
+ - The bundled model carries calibrated defaults in its `classifier_config.json` (`high_risk_threshold` 0.52 at temperature 2.41). They override library defaults when the model loads. Thresholds are model-specific: 0.64 was right for the previous model, not this one.
117
+ - Multi-head mode is off by default; Tier 2 then rates a chunk high at `high_risk_threshold` (blocking also needs `block_high_risk=True`). To turn it on, pass a preset as `tier2_config={"multihead": MultiheadConfig(main_threshold=..., aux_threshold=...)}`: a chunk then blocks when its main score >= `main_threshold` and its aux score < `aux_threshold`, and the result reports `tier2_aux_score` and `tier2_multihead_blocked`. Presets tuned for this model: High 0.77/0.325, Medium 0.88/0.325, Low 0.94/0.30. They're also in `classifier_config.json` under `multihead_presets`, but defender doesn't load them for you.
117
118
 
118
119
  ### Optional SFE preprocessor
119
120
 
@@ -122,14 +123,16 @@ Packed-chunk MiniLM classifier (int8 ONNX ~22 MB, bundled):
122
123
  - **Tier 2** extracts strings from the SFE-filtered tree; `fields_dropped` lists paths omitted from that extraction (not removed from `sanitized`)
123
124
  - Fails open if the runtime/model is unavailable: payload continues unfiltered
124
125
 
125
- **Benchmarks** (F1 @ threshold 0.5):
126
+ **Benchmarks** (Tier 2 only, through the package pipeline at the default threshold 0.52; measured 2026-10-20):
126
127
 
127
- | Benchmark | F1 | Samples |
128
- |-----------|-----|--------|
129
- | Qualifire (in-distribution) | 0.8686 | ~1.5k |
130
- | xxz224 (out-of-distribution) | 0.8834 | ~22.5k |
131
- | jayavibhav (adversarial) | 0.9717 | ~1k |
132
- | **Average** | **0.9079** | ~25k |
128
+ | Benchmark | F1 | Precision | Recall | Samples |
129
+ |-----------|-----|-----------|--------|---------|
130
+ | Qualifire (held-out test split; the dataset's train split is used in training) | 0.828 | 0.857 | 0.800 | 751 |
131
+ | xxz224 (out-of-distribution) | 0.814 | 0.989 | 0.691 | ~22.5k |
132
+ | jayavibhav (unseen rows; other rows of this split are used in training) | 0.955 | 0.960 | 0.950 | ~60k |
133
+ | **Average** | **0.865** | | | |
134
+
135
+ The previous model scores 0.843 on the same sets and method. Earlier READMEs showed 0.908 from a different method (raw scores, best threshold picked per dataset), which isn't comparable. Latency is about 10 ms per call (p50 on a 2-vCPU container, short inputs); long inputs that split into several chunks take longer.
133
136
 
134
137
  ### Optional Tier 3 — LLM adjudication (bring your own LLM)
135
138
 
@@ -239,7 +242,7 @@ A failed chunk is skipped (fail open), and `result.tier3.errored` and `coverage_
239
242
  | Option | Default | What it does |
240
243
  |---|---|---|
241
244
  | `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`. |
242
- | `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"`. |
245
+ | `on_error` | cascade `"tier2"`, tier3_only `"allow"` | What a failure (error, timeout, invalid verdict) does. `"tier2"` keeps Tier 2's verdict. `"allow"` treats it as an allow. `"block"` blocks: in cascade when Tier 2 rated the payload medium or higher (with the bundled model Tier 2 rarely reports `medium`: its band is [0.5, 0.52)); in tier3_only when any chunk failed (and `on_oversize` then defaults to `"scan_anyway"`). A missing provider fails closed too. `"tier2"` isn't valid in tier3_only: defender warns and uses `"allow"`. |
243
246
  | `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`. |
244
247
  | `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. |
245
248
  | `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. |
@@ -284,7 +287,7 @@ defense = create_prompt_defense(
284
287
  annotate_boundary=False, # True: wrap risky strings with [UD-…] tags (npm: annotateBoundary)
285
288
  sanitize_content=True, # False: detect-and-gate; sanitized is the input verbatim
286
289
  tier2_fields=["subject", "body", "snippet"], # optional: scope Tier 2 to these JSON keys (default: all strings)
287
- tier2_config={"high_risk_threshold": 0.64}, # optional; defaults come from the bundled model's classifier_config.json
290
+ tier2_config={"high_risk_threshold": 0.52}, # optional; defaults come from the bundled model's classifier_config.json
288
291
  use_sfe=False, # True, or {"threshold": 0.5, "predictor": ...}: SFE preprocessing before Tier 2
289
292
  enable_tier3=False,
290
293
  defender_mode="cascade", # or "tier3_only"; ignored unless enable_tier3
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "stackone-defender"
3
- version = "0.9.2"
3
+ version = "0.9.3"
4
4
  description = "Indirect prompt injection defense for AI agents using tool calls"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -0,0 +1,75 @@
1
+ {
2
+ "model_id": "sentence-transformers/all-MiniLM-L6-v2",
3
+ "model_type": "minilm",
4
+ "use_seq_clf": false,
5
+ "hidden_size": 384,
6
+ "freeze_layers": 4,
7
+ "pooling": "mean",
8
+ "optimal_threshold": 0.15,
9
+ "datasets": [
10
+ "qualifire",
11
+ "jayavibhav",
12
+ "agentdojo",
13
+ "jasperls",
14
+ "jailbreakbench",
15
+ "toxic-chat",
16
+ "chatgpt-jailbreaks",
17
+ "email-hardneg",
18
+ "email-hardneg-gen",
19
+ "multilingual-hardneg",
20
+ "jailbreakbench-neg",
21
+ "toxic-chat-neg",
22
+ "fujitsu-injecagent",
23
+ "fujitsu-rag",
24
+ "enron-ham",
25
+ "connector-hardneg-v2",
26
+ "dev-tooling-hardneg-curated",
27
+ "dev-tooling-attacks",
28
+ "agentshield-shape-attacks",
29
+ "system-prompt-extraction-attacks",
30
+ "emoji-ci-benign",
31
+ "benign-user-queries",
32
+ "code-docs-benign"
33
+ ],
34
+ "token_level": false,
35
+ "token_pool": "max",
36
+ "token_topk": 5,
37
+ "three_class": false,
38
+ "multi_head": true,
39
+ "aux_loss_alpha": 0.5,
40
+ "extra_benchmarks": [
41
+ "hd-negatives-v1",
42
+ "hd-pairs-v1",
43
+ "hd-negatives-v2",
44
+ "hd-pairs-v2",
45
+ "hd-negatives-v4",
46
+ "hd-pairs-v4",
47
+ "hd-short-v1",
48
+ "hd-negatives-v5",
49
+ "hd-pairs-v5",
50
+ "hd-pairs-agent-v1"
51
+ ],
52
+ "variant": "jbv2-mh-grid-agpjb2pinstr-s2",
53
+ "calibration": {
54
+ "temperatureT": 2.41,
55
+ "highRiskThreshold": 0.52,
56
+ "ece": 0.09,
57
+ "fitted_on": "labeled plugin events 2026-05-13",
58
+ "notes": "tier2Score is the calibrated probability sigmoid(logit/T) at T=2.41. highRiskThreshold 0.52 is the single-head operating point for this model, chosen to match the shipped false-positive rate (v5 used 0.64). Do not reuse 0.64 with this model."
59
+ },
60
+ "multihead_presets": {
61
+ "High": {
62
+ "mainThreshold": 0.77,
63
+ "auxThreshold": 0.325
64
+ },
65
+ "Medium": {
66
+ "mainThreshold": 0.88,
67
+ "auxThreshold": 0.325
68
+ },
69
+ "Low": {
70
+ "mainThreshold": 0.94,
71
+ "auxThreshold": 0.3
72
+ },
73
+ "notes": "Tuned 2026-10-14 for this model only (docs/2026-09-30-tier2-prompt-injection-identifiability.md, release gate 1). Do not reuse for another model."
74
+ }
75
+ }
@@ -0,0 +1 @@
1
+ defender-v0.9.3
@@ -1737,6 +1737,37 @@
1737
1737
  "coverageDegraded": false,
1738
1738
  "judgeCalls": 1
1739
1739
  }
1740
+ },
1741
+ {
1742
+ "id": "t3only-refused-without-decision-blocks",
1743
+ "description": "A refusal blocks even without a valid decision.",
1744
+ "input": {
1745
+ "mode": "tier3_only",
1746
+ "payload": {
1747
+ "body": "x"
1748
+ },
1749
+ "judge": {
1750
+ "default": {
1751
+ "refused": true
1752
+ }
1753
+ }
1754
+ },
1755
+ "expect": {
1756
+ "allowed": false,
1757
+ "riskLevel": "high",
1758
+ "tier3": {
1759
+ "decision": "block",
1760
+ "refused": true,
1761
+ "hasRaw": false
1762
+ },
1763
+ "coverageDegraded": false,
1764
+ "chunkSummary": {
1765
+ "chunks": 1,
1766
+ "blocked": 1,
1767
+ "blockingChunk": 0
1768
+ },
1769
+ "judgeCalls": 1
1770
+ }
1740
1771
  }
1741
1772
  ]
1742
1773
  }
@@ -237,6 +237,56 @@
237
237
  "expect": {
238
238
  "skipReason": "Tier 3 provider error: 😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀…"
239
239
  }
240
+ },
241
+ {
242
+ "id": "url-password-with-at",
243
+ "description": "Userinfo runs to the last @, so a password containing @ doesn't leak into the host.",
244
+ "input": {
245
+ "message": "connect https://user:p@ss@host.example.com/x refused"
246
+ },
247
+ "expect": {
248
+ "skipReason": "Tier 3 provider error: connect https://host.example.com refused"
249
+ }
250
+ },
251
+ {
252
+ "id": "dict-repr-bearer-then-key",
253
+ "description": "The Bearer rule stops at the quote, so the next pair in a dict repr is still redacted.",
254
+ "input": {
255
+ "message": "{'Authorization': 'Bearer abcdefghijklmnop', 'X-Api-Key': 'zzzzzzzzzz'}"
256
+ },
257
+ "expect": {
258
+ "skipReason": "Tier 3 provider error: {'Authorization': '[redacted]', 'X-Api-Key': '[redacted]'}"
259
+ }
260
+ },
261
+ {
262
+ "id": "bearer-quoted-value",
263
+ "description": "A quoted Bearer value is redacted whole.",
264
+ "input": {
265
+ "message": "auth failed: Bearer 'abcdefghijklmnop' and Basic \"dXNlcjpwYXNzd29yZA==\""
266
+ },
267
+ "expect": {
268
+ "skipReason": "Tier 3 provider error: auth failed: Bearer [redacted] and Basic [redacted]"
269
+ }
270
+ },
271
+ {
272
+ "id": "bearer-embedded-and-escaped-quotes",
273
+ "description": "An unquoted Bearer value keeps embedded quotes; a quoted one keeps escaped quotes.",
274
+ "input": {
275
+ "message": "a: Bearer abc'defghijk rest; b: Basic \"abc\\\"defgh\" tail"
276
+ },
277
+ "expect": {
278
+ "skipReason": "Tier 3 provider error: a: Bearer [redacted] rest; b: Basic [redacted] tail"
279
+ }
280
+ },
281
+ {
282
+ "id": "bearer-brace-not-a-delimiter",
283
+ "description": "Only whitespace, a comma or the end stops an unquoted Bearer value, so text after a brace is redacted too.",
284
+ "input": {
285
+ "message": "auth failed: Bearer abc}secretvalue12 and Basic xyz)moresecret34"
286
+ },
287
+ "expect": {
288
+ "skipReason": "Tier 3 provider error: auth failed: Bearer [redacted] and Basic [redacted]"
289
+ }
240
290
  }
241
291
  ]
242
292
  }
@@ -102,7 +102,8 @@ class TestTier2Classifier:
102
102
 
103
103
  def test_risk_levels(self):
104
104
  assert self.classifier.get_risk_level(0.9) == "high"
105
- assert self.classifier.get_risk_level(0.6) == "medium"
105
+ assert self.classifier.get_risk_level(0.52) == "high" # the recalibrated boundary
106
+ assert self.classifier.get_risk_level(0.51) == "medium" # medium is [0.5, 0.52) with the bundled model
106
107
  assert self.classifier.get_risk_level(0.3) == "low"
107
108
 
108
109
  def test_is_injection(self):
@@ -119,7 +120,8 @@ class TestTier2ClassifierNoModel:
119
120
  def test_risk_level_thresholds(self):
120
121
  c = Tier2Classifier()
121
122
  assert c.get_risk_level(0.9) == "high"
122
- assert c.get_risk_level(0.6) == "medium"
123
+ assert c.get_risk_level(0.52) == "high" # the recalibrated boundary
124
+ assert c.get_risk_level(0.51) == "medium" # medium is [0.5, 0.52) with the bundled model
123
125
  assert c.get_risk_level(0.3) == "low"
124
126
  assert c.get_risk_level(0.0) == "low"
125
127
  assert c.get_risk_level(1.0) == "high"
@@ -39,7 +39,8 @@ class TestTier2ClassifierConfig:
39
39
  def test_default_config(self):
40
40
  c = Tier2Classifier()
41
41
  assert c.get_risk_level(0.9) == "high"
42
- assert c.get_risk_level(0.6) == "medium"
42
+ assert c.get_risk_level(0.52) == "high" # the recalibrated boundary
43
+ assert c.get_risk_level(0.51) == "medium" # medium is [0.5, 0.52) with the bundled model
43
44
  assert c.get_risk_level(0.3) == "low"
44
45
 
45
46
  def test_custom_thresholds(self):
@@ -65,10 +66,10 @@ class TestTier2ClassifierConfig:
65
66
  def test_get_config(self):
66
67
  c = Tier2Classifier()
67
68
  cfg = c.get_config()
68
- # The bundled minilm-multihead-v5 model ships calibrated defaults via
69
- # classifier_config.json (T=2.41, high_risk_threshold=0.64). Library
70
- # defaults still apply for fields the model doesn't override.
71
- assert cfg["high_risk_threshold"] == 0.64
69
+ # The bundled model ships calibrated defaults via classifier_config.json
70
+ # (T=2.41, high_risk_threshold=0.52; ENG-3282). Library defaults still
71
+ # apply for fields the model doesn't override.
72
+ assert cfg["high_risk_threshold"] == 0.52
72
73
  assert cfg["medium_risk_threshold"] == 0.5
73
74
  assert cfg["min_text_length"] == 10
74
75
  assert cfg["max_text_length"] == 10000
@@ -113,7 +114,7 @@ class TestCalibrationAutoLoad:
113
114
  def test_thresholds_from_model_calibration(self):
114
115
  c = Tier2Classifier()
115
116
  cfg = c.get_config()
116
- assert cfg["high_risk_threshold"] == 0.64
117
+ assert cfg["high_risk_threshold"] == 0.52
117
118
 
118
119
  def test_caller_overrides_model(self, tmp_path: Path):
119
120
  # Caller-supplied config wins over model calibration.
@@ -13,15 +13,15 @@ wheels = [
13
13
 
14
14
  [[package]]
15
15
  name = "anyio"
16
- version = "4.12.1"
16
+ version = "4.14.2"
17
17
  source = { registry = "https://pypi.org/simple" }
18
18
  dependencies = [
19
19
  { name = "idna" },
20
20
  { name = "typing-extensions", marker = "python_full_version < '3.13'" },
21
21
  ]
22
- sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
22
+ sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
23
23
  wheels = [
24
- { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
24
+ { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
25
25
  ]
26
26
 
27
27
  [[package]]
@@ -175,11 +175,11 @@ wheels = [
175
175
 
176
176
  [[package]]
177
177
  name = "fsspec"
178
- version = "2026.2.0"
178
+ version = "2026.6.0"
179
179
  source = { registry = "https://pypi.org/simple" }
180
- sdist = { url = "https://files.pythonhosted.org/packages/51/7c/f60c259dcbf4f0c47cc4ddb8f7720d2dcdc8888c8e5ad84c73ea4531cc5b/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff", size = 313441, upload-time = "2026-02-05T21:50:53.743Z" }
180
+ sdist = { url = "https://files.pythonhosted.org/packages/10/a1/ae4e3e5003468d6391d2c77b6fa1cd73bd5d13511d81c642d7b28ac90ed4/fsspec-2026.6.0.tar.gz", hash = "sha256:f5bac145310fe30e16e1471bd6840b2d990d609e872251d7e674241822abf01a", size = 313646, upload-time = "2026-06-16T01:57:28.105Z" }
181
181
  wheels = [
182
- { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" },
182
+ { url = "https://files.pythonhosted.org/packages/e5/22/4222d7ddf3da30f363edaa98e329c2bce6c65497c9cb2810931c8b2c0fbc/fsspec-2026.6.0-py3-none-any.whl", hash = "sha256:02e0b71817df9b2169dc30a16832045764def1191b43dcff5bb85bdee212d2a1", size = 203949, upload-time = "2026-06-16T01:57:26.358Z" },
183
183
  ]
184
184
 
185
185
  [[package]]
@@ -646,7 +646,7 @@ wheels = [
646
646
 
647
647
  [[package]]
648
648
  name = "stackone-defender"
649
- version = "0.9.2"
649
+ version = "0.9.3"
650
650
  source = { editable = "." }
651
651
 
652
652
  [package.optional-dependencies]
@@ -1 +0,0 @@
1
- {".":"0.9.2"}
@@ -1,47 +0,0 @@
1
- {
2
- "model_id": "sentence-transformers/all-MiniLM-L6-v2",
3
- "model_type": "minilm",
4
- "use_seq_clf": false,
5
- "hidden_size": 384,
6
- "freeze_layers": 4,
7
- "pooling": "mean",
8
- "optimal_threshold": 0.4,
9
- "datasets": [
10
- "qualifire",
11
- "jayavibhav",
12
- "agentdojo",
13
- "jasperls",
14
- "jailbreakbench",
15
- "toxic-chat",
16
- "chatgpt-jailbreaks",
17
- "email-hardneg",
18
- "email-hardneg-gen",
19
- "multilingual-hardneg",
20
- "jailbreakbench-neg",
21
- "toxic-chat-neg",
22
- "fujitsu-injecagent",
23
- "fujitsu-rag",
24
- "enron-ham",
25
- "connector-hardneg-v2",
26
- "dev-tooling-hardneg-curated",
27
- "dev-tooling-attacks",
28
- "agentshield-shape-attacks",
29
- "system-prompt-extraction-attacks",
30
- "emoji-ci-benign",
31
- "benign-user-queries",
32
- "code-docs-benign"
33
- ],
34
- "token_level": false,
35
- "token_pool": "max",
36
- "token_topk": 5,
37
- "three_class": false,
38
- "multi_head": true,
39
- "aux_loss_alpha": 0.5,
40
- "calibration": {
41
- "temperatureT": 2.41,
42
- "highRiskThreshold": 0.64,
43
- "ece": 0.09,
44
- "fitted_on": "labeled plugin events 2026-05-13",
45
- "notes": "Raw highRiskThreshold 0.8 is math-equivalent to calibrated 0.64 at T=2.41. tier2Score is reported as calibrated probability (post-sigmoid(logit/T))."
46
- }
47
- }
@@ -1 +0,0 @@
1
- defender-v0.9.2