stackone-defender 0.7.4__tar.gz → 0.9.0__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 (68) hide show
  1. stackone_defender-0.9.0/.release-please-manifest.json +1 -0
  2. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/CHANGELOG.md +32 -0
  3. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/PKG-INFO +70 -18
  4. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/README.md +69 -17
  5. stackone_defender-0.9.0/prof.out +0 -0
  6. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/pyproject.toml +1 -1
  7. stackone_defender-0.9.0/scripts/sync_conformance.sh +47 -0
  8. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/__init__.py +2 -0
  9. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/classifiers/onnx_classifier.py +85 -2
  10. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/classifiers/patterns.py +6 -2
  11. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/classifiers/tier2_classifier.py +45 -5
  12. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/config.py +6 -1
  13. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/core/prompt_defense.py +328 -68
  14. stackone_defender-0.9.0/src/stackone_defender/core/sentence_cleaner.py +82 -0
  15. stackone_defender-0.9.0/src/stackone_defender/core/tier3_input.py +634 -0
  16. stackone_defender-0.9.0/src/stackone_defender/core/tool_result_sanitizer.py +460 -0
  17. stackone_defender-0.9.0/src/stackone_defender/sanitizers/__init__.py +15 -0
  18. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/sanitizers/encoding_detector.py +3 -1
  19. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/types.py +51 -3
  20. stackone_defender-0.9.0/tests/conformance/README.md +82 -0
  21. stackone_defender-0.9.0/tests/conformance/VERSION +1 -0
  22. stackone_defender-0.9.0/tests/conformance/tier3-parse.json +176 -0
  23. stackone_defender-0.9.0/tests/conformance/tier3-policy.json +1742 -0
  24. stackone_defender-0.9.0/tests/conformance/tier3-prompt.json +342 -0
  25. stackone_defender-0.9.0/tests/conformance/tier3-redact.json +242 -0
  26. stackone_defender-0.9.0/tests/conformance/tier3-serialize.json +3794 -0
  27. stackone_defender-0.9.0/tests/conformance_known_failures.txt +97 -0
  28. stackone_defender-0.9.0/tests/test_conformance.py +357 -0
  29. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/tests/test_integration.py +205 -8
  30. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/tests/test_onnx_classifier.py +3 -4
  31. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/tests/test_sanitizers.py +0 -194
  32. stackone_defender-0.9.0/tests/test_sentence_cleaner.py +87 -0
  33. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/tests/test_tier2_classifier.py +94 -0
  34. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/tests/test_tier3.py +287 -14
  35. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/uv.lock +2 -2
  36. stackone_defender-0.7.4/.release-please-manifest.json +0 -1
  37. stackone_defender-0.7.4/src/stackone_defender/core/tool_result_sanitizer.py +0 -361
  38. stackone_defender-0.7.4/src/stackone_defender/sanitizers/__init__.py +0 -31
  39. stackone_defender-0.7.4/src/stackone_defender/sanitizers/pattern_remover.py +0 -113
  40. stackone_defender-0.7.4/src/stackone_defender/sanitizers/sanitizer.py +0 -243
  41. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/.gitignore +0 -0
  42. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/.python-version +0 -0
  43. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/.release-please-config.json +0 -0
  44. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/LICENSE +0 -0
  45. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/classifiers/__init__.py +0 -0
  46. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/classifiers/pattern_detector.py +0 -0
  47. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/classifiers/tier3_orchestrator.py +0 -0
  48. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/core/__init__.py +0 -0
  49. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/models/minilm-multihead-v5/classifier_config.json +0 -0
  50. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/models/minilm-multihead-v5/config.json +0 -0
  51. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/models/minilm-multihead-v5/model_quantized.onnx +0 -0
  52. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/models/minilm-multihead-v5/tokenizer.json +0 -0
  53. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/models/minilm-multihead-v5/tokenizer_config.json +0 -0
  54. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/py.typed +0 -0
  55. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/sanitizers/leet_normalizer.py +0 -0
  56. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/sanitizers/normalizer.py +0 -0
  57. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/sanitizers/role_stripper.py +0 -0
  58. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/sfe/__init__.py +0 -0
  59. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/sfe/model.ftz +0 -0
  60. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/sfe/preprocess.py +0 -0
  61. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/utils/__init__.py +0 -0
  62. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/utils/boundary.py +0 -0
  63. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/utils/field_detection.py +0 -0
  64. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/utils/structure.py +0 -0
  65. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/tests/__init__.py +0 -0
  66. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/tests/test_pattern_detector.py +0 -0
  67. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/tests/test_sfe.py +0 -0
  68. {stackone_defender-0.7.4 → stackone_defender-0.9.0}/tests/test_utils.py +0 -0
@@ -0,0 +1 @@
1
+ {".":"0.9.0"}
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.0](https://github.com/StackOneHQ/defender-py/compare/stackone-defender-v0.8.2...stackone-defender-v0.9.0) (2026-10-20)
4
+
5
+
6
+ ### Features
7
+
8
+ * **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))
9
+
10
+
11
+ ### Miscellaneous Chores
12
+
13
+ * **ENG-3500:** release 0.9.0 ([#37](https://github.com/StackOneHQ/defender-py/issues/37)) ([aa4a4e3](https://github.com/StackOneHQ/defender-py/commit/aa4a4e39fcb7a7adc6d43fbab8d7b84817613dbc))
14
+
15
+ ## [0.8.2](https://github.com/StackOneHQ/defender-py/compare/stackone-defender-v0.7.4...stackone-defender-v0.8.2) (2026-08-19)
16
+
17
+
18
+ ### Features
19
+
20
+ * **ENG-1084:** detect-and-gate + sentence-level cleaning — Tier 1 detects without mutating content (gate on `allowed`); `sanitized` is a sentence-cleaned copy of high/critical fields ([60b01d0](https://github.com/StackOneHQ/defender-py/commit/60b01d0117c04106a3017dc4bf536cce6cc7a6c4))
21
+ * **ENG-1084:** mark dropped runs with `[CONTENT SANITISED]` in the cleaned copy ([fdcdb94](https://github.com/StackOneHQ/defender-py/commit/fdcdb94e27698ac71a8dd8933d54ad47455be376))
22
+ * **ENG-1084:** `detected_field_count` threat-count signal; object-key scanning; Tier 2 availability (`require_tier2` / `tier2_available`) ([6f1b52a](https://github.com/StackOneHQ/defender-py/commit/6f1b52afd9d2932aab9462776c102d05e5bd9626))
23
+ * **ENG-1084:** NFKC-fold unicode before Tier 2 classification ([50741ee](https://github.com/StackOneHQ/defender-py/commit/50741eeb7c9d7e435a2a504d2f83c3bd9b2b8c04))
24
+
25
+
26
+ ### Bug Fixes
27
+
28
+ * **ENG-1084:** return large arrays in full — 0.7.4 truncated arrays >1000 items to 100 + a sentinel, dropping the rest; Tier 1 detection is now bounded by the `max_size` budget (flagged via `coverage_degraded`), never drops data ([26c7eec](https://github.com/StackOneHQ/defender-py/commit/26c7eecb10f55be0a9e7cb614a67629fa485d199))
29
+ * **ENG-1084:** scan strings inside risky array fields (`{"name": [INJ]}`) ([6f1b52a](https://github.com/StackOneHQ/defender-py/commit/6f1b52afd9d2932aab9462776c102d05e5bd9626))
30
+ * **ENG-1084:** evidence-driven encoding escalation; token-degeneracy (OOD) guard; decorative-run normalization ([f450992](https://github.com/StackOneHQ/defender-py/commit/f45099241e36ee4210daaf39f2573c2a88d3071b))
31
+ * **ENG-1084:** gate sentence-cleaning on aggregate risk; verbatim when nothing is dropped ([9e25335](https://github.com/StackOneHQ/defender-py/commit/9e2533576f90574f8bab5476728d79c5275b9060))
32
+ * **ENG-1084:** count objects/arrays once in size metrics + iterate `obj.items()` directly ([26c7eec](https://github.com/StackOneHQ/defender-py/commit/26c7eecb10f55be0a9e7cb614a67629fa485d199))
33
+ * **ENG-1084:** warmup fail-open + dict-subclass stripping ([f49ffcf](https://github.com/StackOneHQ/defender-py/commit/f49ffcf6e93e322623447d217994cc8215e02410))
34
+
3
35
  ## [0.7.4](https://github.com/StackOneHQ/defender-py/compare/stackone-defender-v0.7.3...stackone-defender-v0.7.4) (2026-08-13)
4
36
 
5
37
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: stackone-defender
3
- Version: 0.7.4
3
+ Version: 0.9.0
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
@@ -116,10 +116,12 @@ else:
116
116
 
117
117
  ### Tier 1 — Pattern detection (sync, ~1 ms)
118
118
 
119
- - **Unicode normalization** — homoglyph resistance (e.g. Cyrillic `а` → ASCII `a`)
120
- - **Role stripping** — `SYSTEM:`, `ASSISTANT:`, `<system>`, `[INST]`, etc.
121
- - **Pattern removal** — phrases like “ignore previous instructions”
122
- - **Encoding detection** — suspicious Base64/URL-shaped payloads
119
+ Detects (never rewrites) injection signals and records them as evidence:
120
+
121
+ - **Unicode normalization** (for matching only) — homoglyph resistance (e.g. Cyrillic `а` → ASCII `a`)
122
+ - **Role markers** — `SYSTEM:`, `ASSISTANT:`, `<system>`, `[INST]`, etc.
123
+ - **Instruction-override patterns** — phrases like “ignore previous instructions”
124
+ - **Encoding detection** — decode-then-detect: a decoded payload is escalated only if it trips a real attack pattern
123
125
  - **Boundary annotation (opt-in)** — `[UD-{id}]…[/UD-{id}]` wrappers when `annotate_boundary=True` (npm: `annotateBoundary`). Use `generate_boundary_instructions` from the package root in prompts when you enable wrapping.
124
126
 
125
127
  ### Tier 2 — ML classification (ONNX)
@@ -133,7 +135,7 @@ Packed-chunk MiniLM classifier (int8 ONNX ~22 MB, bundled):
133
135
  ### Optional SFE preprocessor
134
136
 
135
137
  - `use_sfe=True` runs a field-level FastText pass to build a **classifier-only** view of the payload
136
- - **Tier 1** always sanitizes the **original** tool value; **`sanitized`** in `DefenseResult` is unchanged by SFE drops
138
+ - **Tier 1** detects on the raw tool value; SFE drops are classifier-only and never remove fields from the returned `sanitized` payload
137
139
  - **Tier 2** extracts strings from the SFE-filtered tree; `fields_dropped` lists paths omitted from that extraction (not removed from `sanitized`)
138
140
  - Fails open if the runtime/model is unavailable: payload continues unfiltered
139
141
 
@@ -152,7 +154,7 @@ Authoritative LLM-based classification for the cases Tier 2 finds ambiguous. The
152
154
 
153
155
  Two modes, selected via `defender_mode`:
154
156
  - **`"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.
155
- - **`"tier3_only"`**: skip Tier 2; the block/allow decision is the Tier 3 verdict alone. Tier 1 still sanitizes the returned `sanitized` payload.
157
+ - **`"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.
156
158
 
157
159
  Register a provider once at startup, then opt in per instance:
158
160
 
@@ -177,20 +179,36 @@ defense = create_prompt_defense(
177
179
  tier3={
178
180
  "provider": MyProvider(), # optional: overrides the registry for this instance
179
181
  "escalation_band": {"lower": 0.3, "upper": 0.85}, # cascade gray band; [lower, upper), defaults shown
180
- "max_text_length": 10000, # caps text passed to the provider
182
+ "max_text_length": 10000, # max chars per provider call
183
+ "max_chunks": 5, # tier3_only: chunks reviewed per tool result
184
+ "on_oversize": "skip", # tier3_only: "skip" | "block" | "scan_anyway"
181
185
  "block_threshold": 0.622, # optional; decide on score, not the model's word
182
186
  },
183
187
  )
184
188
  ```
185
189
 
190
+ **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)`.
191
+
192
+ 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`.
193
+
194
+ **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`.
195
+ - `"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.
196
+ - `"block"`: block without calling the provider. `tier3_chunk_summary` is `None` and `result.tier3.skip_reason` says why.
197
+ - `"scan_anyway"`: review what fits, then block on the unreviewed rest unless a chunk already blocked.
198
+
199
+ In `"skip"` and `"scan_anyway"`, `tier3_chunk_summary.oversize` is `True`.
200
+
201
+ 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.
202
+
186
203
  **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.
187
204
 
188
205
  **Fail-open semantics.** A provider error or timeout records a `skip_reason` on `result.tier3`; in cascade defender falls back to the Tier 2 decision, in `tier3_only` it allows the request. `enable_tier3=True` with no registered provider falls back to the standard Tier 1 + Tier 2 cascade and logs one warning per instance — Tier 3 misconfiguration never silently disables defense. `result.tier3` carries the verdict (a `Tier3Verdict`, or a `Tier3Skip` when the provider ran but returned nothing usable) when Tier 3 runs, and is `None` when it doesn't.
189
206
 
190
207
  ### `allowed` vs `risk_level`
191
208
 
209
+ - **`DefenseResult.sanitized`** is a **sentence-level cleaned** copy of the tool result (high-scoring sentences dropped within high-risk fields, optionally `[UD-…]` boundary-wrapped). Cleaning is best-effort (capped by detection) — still gate on `allowed`. Set `sanitize_content=False` for pure detect-and-gate: `sanitized` is then the input verbatim.
192
210
  - Use **`allowed`** for gating when `block_high_risk=True`: `False` means do not pass `sanitized` to the model as-is.
193
- - **`risk_level`** is diagnostic: it starts at `default_risk_level` (default `"medium"`) and is **escalated** by Tier 1 / Tier 2 signals — not reduced. Use it for logging, not as the sole block signal unless you implement your own policy.
211
+ - **`risk_level`** is diagnostic: it starts at `default_risk_level` (default `"low"`) and is **escalated** by Tier 1 / Tier 2 signals — not reduced. Use it for logging, not as the sole block signal unless you implement your own policy.
194
212
 
195
213
  | Level | Typical trigger |
196
214
  |-------|------------------|
@@ -206,8 +224,9 @@ defense = create_prompt_defense(
206
224
  defense = create_prompt_defense(
207
225
  enable_tier1=True,
208
226
  enable_tier2=True,
227
+ require_tier2=False, # True: raise if Tier 2 can't load (fail closed) instead of degrading to Tier 1
209
228
  block_high_risk=False,
210
- default_risk_level="medium",
229
+ default_risk_level="low",
211
230
  annotate_boundary=False, # True: wrap risky strings with [UD-…] tags (npm: annotateBoundary)
212
231
  tier2_fields=["subject", "body", "snippet"], # optional: scope Tier 2 to these JSON keys (default: all strings)
213
232
  use_sfe=True, # optional: enable semantic field extractor preprocessing
@@ -229,18 +248,32 @@ from dataclasses import dataclass, field
229
248
 
230
249
  @dataclass
231
250
  class DefenseResult:
232
- allowed: bool
233
- risk_level: RiskLevel
234
- sanitized: Any
235
- detections: list[str]
236
- fields_sanitized: list[str]
237
- patterns_by_field: dict[str, list[str]]
251
+ allowed: bool # gating decision (respects block_high_risk)
252
+ risk_level: RiskLevel # diagnostic; max of Tier 1 / Tier 2
253
+ sanitized: Any # sentence-cleaned copy (input verbatim when sanitize_content=False); dropped runs leave a [CONTENT SANITISED] marker; best-effort, still gate on allowed
254
+ detections: list[str] # Tier 1 pattern names detected
255
+ fields_sanitized: list[str] # fields whose content the cleaner changed in sanitized (empty when sanitize_content=False or no Tier 2); for detections read detections/patterns_by_field
256
+ patterns_by_field: dict[str, list[str]] # patterns detected per field
257
+ detected_field_count: int # count of fields with a Tier-1 detection (keys of patterns_by_field); threat-count signal (fields_sanitized len no longer tracks this)
238
258
  tier2_score: float | None = None
259
+ tier2_raw_score: float | None = None
260
+ tier2_aux_score: float | None = None # multi-head models only
261
+ tier2_multihead_blocked: bool | None = None
239
262
  tier2_skip_reason: str | None = None
240
263
  max_sentence: str | None = None
264
+ tier3: Tier3Result | None = None # present when Tier 3 ran
241
265
  fields_dropped: list[str] = field(default_factory=list)
242
266
  truncated_at_depth: bool | None = None
243
267
  latency_ms: float = 0.0
268
+ # Cost telemetry — present only when the batched Tier 2 classifier ran
269
+ phase_timings: PhaseTimings | None = None # prepare / infer / aggregate ms
270
+ tier2_stats: Tier2Stats | None = None # string/chunk/unique counts, real/padded tokens
271
+ tier1_ms: float | None = None
272
+ cold_load: bool | None = None
273
+ # Operational signals
274
+ tier2_available: bool | None = None # False when Tier 2 enabled but failed to load
275
+ coverage_degraded: bool | None = None # True when Tier 1 detection or the Tier 3 review didn't cover everything
276
+ tier3_chunk_summary: Tier3ChunkSummary | None = None # tier3_only: chunks, blocked, blocking_chunk, oversize
244
277
  ```
245
278
 
246
279
  ### `defense.defend_tool_results(items)`
@@ -255,7 +288,7 @@ results = defense.defend_tool_results([
255
288
  ])
256
289
  for r in results:
257
290
  if not r.allowed:
258
- print("Blocked:", ", ".join(r.fields_sanitized))
291
+ print("Blocked:", ", ".join(r.detections))
259
292
  ```
260
293
 
261
294
  ### `await defense.defend_tool_results_async(items)`
@@ -300,7 +333,7 @@ sanitized = run_tool_and_defend(gmail_api.get_message(msg_id), "gmail_get_messag
300
333
 
301
334
  ## Risky field detection
302
335
 
303
- Only **string** values under configured “risky” keys are scanned and sanitized. [`RiskyFieldConfig`](https://github.com/StackOneHQ/stackone-defender/blob/main/src/stackone_defender/types.py) provides global names/patterns plus **`tool_overrides`** (wildcard tool names → field list), same idea as the npm package.
336
+ Only **string** values under configured “risky” keys are Tier-1-scanned — including strings nested inside arrays/objects under those keys (e.g. `{"name": ["…"]}`). [`RiskyFieldConfig`](https://github.com/StackOneHQ/stackone-defender/blob/main/src/stackone_defender/types.py) provides global names/patterns plus **`tool_overrides`** (wildcard tool names → field list), same idea as the npm package. (Tier 2 scans all extracted strings regardless.)
304
337
 
305
338
  | Tool pattern | Scanned fields |
306
339
  |--------------|----------------|
@@ -320,6 +353,25 @@ uv sync --group dev
320
353
  uv run pytest
321
354
  ```
322
355
 
356
+ ### Parity with the TypeScript package
357
+
358
+ 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
+
360
+ - `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
+ - Cases this package doesn't match yet are listed in `tests/conformance_known_failures.txt` and run as strict xfails. When a port makes one pass, remove its line.
362
+ - 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
+
364
+ Traps when porting from TypeScript (the fixtures pin TS behavior):
365
+
366
+ - Lengths and slicing are in UTF-16 code units in TS, but `len()` counts code points.
367
+ - TS `Object.entries` puts integer-like keys first. `json.loads` keeps insertion order.
368
+ - 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.
369
+ - `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.
370
+ - Python `re` has no `\p{Cf}`, and `unicodedata` versions differ across Python releases.
371
+ - `str.splitlines()` also splits on `\x0b`, `\x0c` and `\x1c`, but TS splits only on `\r`, `\n`, `\u2028`, `\u2029` and `\u0085`.
372
+ - JS `trim()` removes a BOM, but `str.strip()` doesn't.
373
+ - `json.loads` accepts `NaN`, but JS `JSON.parse` rejects it. Use `parse_constant` to reject it.
374
+
323
375
  ## License
324
376
 
325
377
  Apache-2.0 — see [LICENSE](./LICENSE).
@@ -90,10 +90,12 @@ else:
90
90
 
91
91
  ### Tier 1 — Pattern detection (sync, ~1 ms)
92
92
 
93
- - **Unicode normalization** — homoglyph resistance (e.g. Cyrillic `а` → ASCII `a`)
94
- - **Role stripping** — `SYSTEM:`, `ASSISTANT:`, `<system>`, `[INST]`, etc.
95
- - **Pattern removal** — phrases like “ignore previous instructions”
96
- - **Encoding detection** — suspicious Base64/URL-shaped payloads
93
+ Detects (never rewrites) injection signals and records them as evidence:
94
+
95
+ - **Unicode normalization** (for matching only) — homoglyph resistance (e.g. Cyrillic `а` → ASCII `a`)
96
+ - **Role markers** — `SYSTEM:`, `ASSISTANT:`, `<system>`, `[INST]`, etc.
97
+ - **Instruction-override patterns** — phrases like “ignore previous instructions”
98
+ - **Encoding detection** — decode-then-detect: a decoded payload is escalated only if it trips a real attack pattern
97
99
  - **Boundary annotation (opt-in)** — `[UD-{id}]…[/UD-{id}]` wrappers when `annotate_boundary=True` (npm: `annotateBoundary`). Use `generate_boundary_instructions` from the package root in prompts when you enable wrapping.
98
100
 
99
101
  ### Tier 2 — ML classification (ONNX)
@@ -107,7 +109,7 @@ Packed-chunk MiniLM classifier (int8 ONNX ~22 MB, bundled):
107
109
  ### Optional SFE preprocessor
108
110
 
109
111
  - `use_sfe=True` runs a field-level FastText pass to build a **classifier-only** view of the payload
110
- - **Tier 1** always sanitizes the **original** tool value; **`sanitized`** in `DefenseResult` is unchanged by SFE drops
112
+ - **Tier 1** detects on the raw tool value; SFE drops are classifier-only and never remove fields from the returned `sanitized` payload
111
113
  - **Tier 2** extracts strings from the SFE-filtered tree; `fields_dropped` lists paths omitted from that extraction (not removed from `sanitized`)
112
114
  - Fails open if the runtime/model is unavailable: payload continues unfiltered
113
115
 
@@ -126,7 +128,7 @@ Authoritative LLM-based classification for the cases Tier 2 finds ambiguous. The
126
128
 
127
129
  Two modes, selected via `defender_mode`:
128
130
  - **`"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.
129
- - **`"tier3_only"`**: skip Tier 2; the block/allow decision is the Tier 3 verdict alone. Tier 1 still sanitizes the returned `sanitized` payload.
131
+ - **`"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.
130
132
 
131
133
  Register a provider once at startup, then opt in per instance:
132
134
 
@@ -151,20 +153,36 @@ defense = create_prompt_defense(
151
153
  tier3={
152
154
  "provider": MyProvider(), # optional: overrides the registry for this instance
153
155
  "escalation_band": {"lower": 0.3, "upper": 0.85}, # cascade gray band; [lower, upper), defaults shown
154
- "max_text_length": 10000, # caps text passed to the provider
156
+ "max_text_length": 10000, # max chars per provider call
157
+ "max_chunks": 5, # tier3_only: chunks reviewed per tool result
158
+ "on_oversize": "skip", # tier3_only: "skip" | "block" | "scan_anyway"
155
159
  "block_threshold": 0.622, # optional; decide on score, not the model's word
156
160
  },
157
161
  )
158
162
  ```
159
163
 
164
+ **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)`.
165
+
166
+ 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`.
167
+
168
+ **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`.
169
+ - `"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.
170
+ - `"block"`: block without calling the provider. `tier3_chunk_summary` is `None` and `result.tier3.skip_reason` says why.
171
+ - `"scan_anyway"`: review what fits, then block on the unreviewed rest unless a chunk already blocked.
172
+
173
+ In `"skip"` and `"scan_anyway"`, `tier3_chunk_summary.oversize` is `True`.
174
+
175
+ 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.
176
+
160
177
  **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.
161
178
 
162
179
  **Fail-open semantics.** A provider error or timeout records a `skip_reason` on `result.tier3`; in cascade defender falls back to the Tier 2 decision, in `tier3_only` it allows the request. `enable_tier3=True` with no registered provider falls back to the standard Tier 1 + Tier 2 cascade and logs one warning per instance — Tier 3 misconfiguration never silently disables defense. `result.tier3` carries the verdict (a `Tier3Verdict`, or a `Tier3Skip` when the provider ran but returned nothing usable) when Tier 3 runs, and is `None` when it doesn't.
163
180
 
164
181
  ### `allowed` vs `risk_level`
165
182
 
183
+ - **`DefenseResult.sanitized`** is a **sentence-level cleaned** copy of the tool result (high-scoring sentences dropped within high-risk fields, optionally `[UD-…]` boundary-wrapped). Cleaning is best-effort (capped by detection) — still gate on `allowed`. Set `sanitize_content=False` for pure detect-and-gate: `sanitized` is then the input verbatim.
166
184
  - Use **`allowed`** for gating when `block_high_risk=True`: `False` means do not pass `sanitized` to the model as-is.
167
- - **`risk_level`** is diagnostic: it starts at `default_risk_level` (default `"medium"`) and is **escalated** by Tier 1 / Tier 2 signals — not reduced. Use it for logging, not as the sole block signal unless you implement your own policy.
185
+ - **`risk_level`** is diagnostic: it starts at `default_risk_level` (default `"low"`) and is **escalated** by Tier 1 / Tier 2 signals — not reduced. Use it for logging, not as the sole block signal unless you implement your own policy.
168
186
 
169
187
  | Level | Typical trigger |
170
188
  |-------|------------------|
@@ -180,8 +198,9 @@ defense = create_prompt_defense(
180
198
  defense = create_prompt_defense(
181
199
  enable_tier1=True,
182
200
  enable_tier2=True,
201
+ require_tier2=False, # True: raise if Tier 2 can't load (fail closed) instead of degrading to Tier 1
183
202
  block_high_risk=False,
184
- default_risk_level="medium",
203
+ default_risk_level="low",
185
204
  annotate_boundary=False, # True: wrap risky strings with [UD-…] tags (npm: annotateBoundary)
186
205
  tier2_fields=["subject", "body", "snippet"], # optional: scope Tier 2 to these JSON keys (default: all strings)
187
206
  use_sfe=True, # optional: enable semantic field extractor preprocessing
@@ -203,18 +222,32 @@ from dataclasses import dataclass, field
203
222
 
204
223
  @dataclass
205
224
  class DefenseResult:
206
- allowed: bool
207
- risk_level: RiskLevel
208
- sanitized: Any
209
- detections: list[str]
210
- fields_sanitized: list[str]
211
- patterns_by_field: dict[str, list[str]]
225
+ allowed: bool # gating decision (respects block_high_risk)
226
+ risk_level: RiskLevel # diagnostic; max of Tier 1 / Tier 2
227
+ sanitized: Any # sentence-cleaned copy (input verbatim when sanitize_content=False); dropped runs leave a [CONTENT SANITISED] marker; best-effort, still gate on allowed
228
+ detections: list[str] # Tier 1 pattern names detected
229
+ fields_sanitized: list[str] # fields whose content the cleaner changed in sanitized (empty when sanitize_content=False or no Tier 2); for detections read detections/patterns_by_field
230
+ patterns_by_field: dict[str, list[str]] # patterns detected per field
231
+ detected_field_count: int # count of fields with a Tier-1 detection (keys of patterns_by_field); threat-count signal (fields_sanitized len no longer tracks this)
212
232
  tier2_score: float | None = None
233
+ tier2_raw_score: float | None = None
234
+ tier2_aux_score: float | None = None # multi-head models only
235
+ tier2_multihead_blocked: bool | None = None
213
236
  tier2_skip_reason: str | None = None
214
237
  max_sentence: str | None = None
238
+ tier3: Tier3Result | None = None # present when Tier 3 ran
215
239
  fields_dropped: list[str] = field(default_factory=list)
216
240
  truncated_at_depth: bool | None = None
217
241
  latency_ms: float = 0.0
242
+ # Cost telemetry — present only when the batched Tier 2 classifier ran
243
+ phase_timings: PhaseTimings | None = None # prepare / infer / aggregate ms
244
+ tier2_stats: Tier2Stats | None = None # string/chunk/unique counts, real/padded tokens
245
+ tier1_ms: float | None = None
246
+ cold_load: bool | None = None
247
+ # Operational signals
248
+ tier2_available: bool | None = None # False when Tier 2 enabled but failed to load
249
+ coverage_degraded: bool | None = None # True when Tier 1 detection or the Tier 3 review didn't cover everything
250
+ tier3_chunk_summary: Tier3ChunkSummary | None = None # tier3_only: chunks, blocked, blocking_chunk, oversize
218
251
  ```
219
252
 
220
253
  ### `defense.defend_tool_results(items)`
@@ -229,7 +262,7 @@ results = defense.defend_tool_results([
229
262
  ])
230
263
  for r in results:
231
264
  if not r.allowed:
232
- print("Blocked:", ", ".join(r.fields_sanitized))
265
+ print("Blocked:", ", ".join(r.detections))
233
266
  ```
234
267
 
235
268
  ### `await defense.defend_tool_results_async(items)`
@@ -274,7 +307,7 @@ sanitized = run_tool_and_defend(gmail_api.get_message(msg_id), "gmail_get_messag
274
307
 
275
308
  ## Risky field detection
276
309
 
277
- Only **string** values under configured “risky” keys are scanned and sanitized. [`RiskyFieldConfig`](https://github.com/StackOneHQ/stackone-defender/blob/main/src/stackone_defender/types.py) provides global names/patterns plus **`tool_overrides`** (wildcard tool names → field list), same idea as the npm package.
310
+ Only **string** values under configured “risky” keys are Tier-1-scanned — including strings nested inside arrays/objects under those keys (e.g. `{"name": ["…"]}`). [`RiskyFieldConfig`](https://github.com/StackOneHQ/stackone-defender/blob/main/src/stackone_defender/types.py) provides global names/patterns plus **`tool_overrides`** (wildcard tool names → field list), same idea as the npm package. (Tier 2 scans all extracted strings regardless.)
278
311
 
279
312
  | Tool pattern | Scanned fields |
280
313
  |--------------|----------------|
@@ -294,6 +327,25 @@ uv sync --group dev
294
327
  uv run pytest
295
328
  ```
296
329
 
330
+ ### Parity with the TypeScript package
331
+
332
+ 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
+
334
+ - `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
+ - Cases this package doesn't match yet are listed in `tests/conformance_known_failures.txt` and run as strict xfails. When a port makes one pass, remove its line.
336
+ - 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
+
338
+ Traps when porting from TypeScript (the fixtures pin TS behavior):
339
+
340
+ - Lengths and slicing are in UTF-16 code units in TS, but `len()` counts code points.
341
+ - TS `Object.entries` puts integer-like keys first. `json.loads` keeps insertion order.
342
+ - 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.
343
+ - `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.
344
+ - Python `re` has no `\p{Cf}`, and `unicodedata` versions differ across Python releases.
345
+ - `str.splitlines()` also splits on `\x0b`, `\x0c` and `\x1c`, but TS splits only on `\r`, `\n`, `\u2028`, `\u2029` and `\u0085`.
346
+ - JS `trim()` removes a BOM, but `str.strip()` doesn't.
347
+ - `json.loads` accepts `NaN`, but JS `JSON.parse` rejects it. Use `parse_constant` to reject it.
348
+
297
349
  ## License
298
350
 
299
351
  Apache-2.0 — see [LICENSE](./LICENSE).
Binary file
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "stackone-defender"
3
- version = "0.7.4"
3
+ version = "0.9.0"
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,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}"
@@ -28,6 +28,7 @@ from .types import (
28
28
  MultiheadConfig,
29
29
  RiskLevel,
30
30
  Tier1Result,
31
+ Tier3ChunkSummary,
31
32
  Tier3Provider,
32
33
  Tier3TokenUsage,
33
34
  Tier3Verdict,
@@ -44,6 +45,7 @@ __all__ = [
44
45
  "SfePredictor",
45
46
  "SfePreprocessResult",
46
47
  "Tier1Result",
48
+ "Tier3ChunkSummary",
47
49
  "Tier3Provider",
48
50
  "Tier3TokenUsage",
49
51
  "Tier3Verdict",
@@ -75,7 +75,23 @@ class OnnxClassifier:
75
75
  # function of the string alone, not its batch neighbours (deterministic).
76
76
  _PAD_BUCKETS = (32, 64, 128, 256)
77
77
 
78
- def __init__(self, model_path: str | None = None, temperature_t: float | None = None):
78
+ # Token-degeneracy (OOD) guard. Below this many content tokens, token-share
79
+ # is too coarse to mean anything (a 1-2 token row is trivially "dominated");
80
+ # matches the shortest decorative run that still false-fires post-collapse
81
+ # (``─``x3 -> 3 content tokens). Damp only when the row draws on at most
82
+ # _DEGENERACY_MAX_DISTINCT_TOKENS distinct tokens — padding an attack with
83
+ # copies of one token can push the share past 2/3 but cannot remove the
84
+ # attack's own vocabulary, so the distinct-token floor is a structural, not
85
+ # tuned, backstop against that bypass.
86
+ _DEGENERACY_MIN_CONTENT_TOKENS = 3
87
+ _DEGENERACY_MAX_DISTINCT_TOKENS = 4
88
+
89
+ def __init__(
90
+ self,
91
+ model_path: str | None = None,
92
+ temperature_t: float | None = None,
93
+ degeneracy_max_token_share: float | None = None,
94
+ ):
79
95
  self._model_path = model_path or get_default_model_path()
80
96
  self._session = None
81
97
  self._tokenizer = None
@@ -83,6 +99,14 @@ class OnnxClassifier:
83
99
  self._count_tokenizer = None
84
100
  self._max_length = 256
85
101
  self._load_failed = False
102
+ # Token-degeneracy guard threshold; > 1 disables. See _is_degenerate.
103
+ self._degeneracy_max_token_share = 2 / 3
104
+ if degeneracy_max_token_share is not None and math.isfinite(degeneracy_max_token_share):
105
+ self._degeneracy_max_token_share = float(degeneracy_max_token_share)
106
+ # Cached [UNK] id, resolved lazily from the loaded tokenizer. ``None`` =
107
+ # not yet resolved or no [UNK] concept; see _get_unk_token_id.
108
+ self._unk_token_id: int | None = None
109
+ self._unk_resolved = False
86
110
  # Output mode is detected lazily from the logits shape on the first
87
111
  # inference call. ``None`` until then.
88
112
  self._output_mode: Literal["single", "multi"] | None = None
@@ -145,8 +169,10 @@ class OnnxClassifier:
145
169
  import onnxruntime as ort
146
170
  from tokenizers import Tokenizer
147
171
  except ImportError as e:
172
+ # No warning here -- the ImportError propagates to the caller,
173
+ # which owns user-facing messaging (PromptDefense warns once per
174
+ # instance). Warning here logged a line on every failed call.
148
175
  self._load_failed = True
149
- _logger.warning("[defender] ONNX model failed to load: %s", e)
150
176
  raise ImportError(
151
177
  "ONNX dependencies not installed. Install with: pip install stackone-defender[onnx]"
152
178
  ) from e
@@ -198,6 +224,10 @@ class OnnxClassifier:
198
224
  import numpy as np
199
225
 
200
226
  encoding = self._tokenizer.encode(text)
227
+ # Fix 3: token-degeneracy guard — skip inference on off-distribution
228
+ # input; its mean-pooled score is arbitrary. Damp to a benign 0.
229
+ if self._is_degenerate(encoding.ids):
230
+ return 0.0, None
201
231
  input_ids = np.array([encoding.ids], dtype=np.int64)
202
232
  attention_mask = np.array([encoding.attention_mask], dtype=np.int64)
203
233
 
@@ -264,8 +294,61 @@ class OnnxClassifier:
264
294
  for k, orig_idx in enumerate(idxs):
265
295
  pairs[orig_idx] = chunk_pairs[k]
266
296
 
297
+ # Fix 3: token-degeneracy guard — damp off-distribution rows to a benign
298
+ # 0 so they drop out of any upstream max. Reuses the already-computed ids.
299
+ for i, enc in enumerate(encodings):
300
+ if self._is_degenerate(enc.ids):
301
+ pairs[i] = (0.0, None)
302
+
267
303
  return cast(list[tuple[float, float | None]], pairs)
268
304
 
305
+ def _get_unk_token_id(self) -> int | None:
306
+ """Resolve the tokenizer's ``[UNK]`` id, cached. Returns ``None`` when the
307
+ tokenizer has no ``[UNK]`` concept (the guard then skips its factor-3 check)."""
308
+ if self._unk_resolved:
309
+ return self._unk_token_id
310
+ self._unk_resolved = True
311
+ try:
312
+ self._unk_token_id = self._tokenizer.token_to_id("[UNK]")
313
+ except Exception:
314
+ self._unk_token_id = None
315
+ return self._unk_token_id
316
+
317
+ def _is_degenerate(self, ids: list[int]) -> bool:
318
+ """Token-degeneracy (OOD) test over a tokenized row. Damps only when ALL
319
+ of these hold over the content tokens (excluding [CLS]/[SEP]):
320
+
321
+ 1. the single most-frequent token covers >= ``degeneracy_max_token_share``,
322
+ 2. the row draws on <= ``_DEGENERACY_MAX_DISTINCT_TOKENS`` distinct tokens, and
323
+ 3. the dominant token is NOT [UNK].
324
+
325
+ Factor 2 blocks a padding attack; factor 3 blocks a homoglyph attack —
326
+ fullwidth / zero-width / other OOV chars collapse to repeated [UNK], the
327
+ signature of encoding evasion (more suspicious, not less), so those rows
328
+ are left to score rather than suppressed. Reuses the ids the model runs on.
329
+ """
330
+ threshold = self._degeneracy_max_token_share
331
+ if not (0 < threshold <= 1): # disabled
332
+ return False
333
+ has_specials = len(ids) >= 2
334
+ content = ids[1:-1] if has_specials else ids
335
+ n = len(content)
336
+ if n < self._DEGENERACY_MIN_CONTENT_TOKENS:
337
+ return False
338
+ counts: dict[int, int] = {}
339
+ max_freq = 0
340
+ dominant_id = -1
341
+ for tok in content:
342
+ c = counts.get(tok, 0) + 1
343
+ counts[tok] = c
344
+ if c > max_freq:
345
+ max_freq = c
346
+ dominant_id = tok
347
+ if max_freq / n < threshold or len(counts) > self._DEGENERACY_MAX_DISTINCT_TOKENS:
348
+ return False
349
+ unk = self._get_unk_token_id()
350
+ return unk is None or dominant_id != unk
351
+
269
352
  def _classify_batch_chunk_pair(
270
353
  self, encodings: list, pad_to: int | None = None
271
354
  ) -> list[tuple[float, float | None]]:
@@ -134,7 +134,7 @@ ENCODING_SUSPICIOUS_PATTERNS: list[PatternDefinition] = [
134
134
  PatternDefinition("html_entity_abuse", re.compile(r"(?:&#\d{2,4};){4,}|(?:&#x[0-9a-fA-F]{2,4};){4,}", re.I), "encoding_suspicious", "medium", "HTML entity encoding (potential obfuscation)"),
135
135
  PatternDefinition("rot13_mention", re.compile(r"rot13|caesar\s+cipher|decode\s+this", re.I), "encoding_suspicious", "medium", "Mention of ROT13 or similar encoding schemes"),
136
136
  PatternDefinition("binary_string_encoding", re.compile(r"\b[01]{8}(?:\s+[01]{8}){2,}\b"), "encoding_suspicious", "medium", "Binary-encoded string (potential obfuscation)"),
137
- PatternDefinition("morse_code_encoding", re.compile(r"(?:[.-]+\s){4,}[.-]+"), "encoding_suspicious", "low", "Morse code pattern (potential obfuscation)"),
137
+ PatternDefinition("morse_code_encoding", re.compile(r"(?:[.-]{1,8}\s){4,}[.-]{1,8}"), "encoding_suspicious", "low", "Morse code pattern (potential obfuscation)"),
138
138
  PatternDefinition("leetspeak_injection", re.compile(r"1gn0r3|f0rg3t|byp4ss|syst3m|4dm1n|h4ck", re.I), "encoding_suspicious", "medium", "Leetspeak obfuscation of injection keywords"),
139
139
  ]
140
140
 
@@ -170,10 +170,14 @@ INDIRECT_INJECTION_PATTERNS: list[PatternDefinition] = [
170
170
  # ``[config](https://.../system-setup)`` triggered. Real smuggled-
171
171
  # instruction attacks include the full "ignore (all|the|previous|prior)"
172
172
  # phrasing in the URL/anchor.
173
+ # Negated, bounded char classes ([^\]] / [^)]) instead of ``.*?`` so the
174
+ # regex is linear -- the lazy-dot form could backtrack on long unclosed
175
+ # link/URL spans (ReDoS-prone).
173
176
  PatternDefinition(
174
177
  "markdown_hidden_instruction",
175
178
  re.compile(
176
- r"\[.*?\]\(.*?(?:ignore|disregard|forget|override)\W+(?:all|the|previous|prior)\W+.*?\)",
179
+ r"\[[^\]\n]{0,200}\]\([^)\n]{0,300}(?:ignore|disregard|forget|override)\W{1,8}"
180
+ r"(?:all|the|previous|prior)\W{1,8}[^)\n]{0,300}\)",
177
181
  re.I,
178
182
  ),
179
183
  "structural",