stackone-defender 0.8.2__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 (64) hide show
  1. stackone_defender-0.9.0/.release-please-manifest.json +1 -0
  2. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/CHANGELOG.md +12 -0
  3. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/PKG-INFO +38 -3
  4. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/README.md +37 -2
  5. stackone_defender-0.9.0/prof.out +0 -0
  6. {stackone_defender-0.8.2 → 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.8.2 → stackone_defender-0.9.0}/src/stackone_defender/__init__.py +2 -0
  9. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/core/prompt_defense.py +192 -53
  10. stackone_defender-0.9.0/src/stackone_defender/core/tier3_input.py +634 -0
  11. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/types.py +21 -1
  12. stackone_defender-0.9.0/tests/conformance/README.md +82 -0
  13. stackone_defender-0.9.0/tests/conformance/VERSION +1 -0
  14. stackone_defender-0.9.0/tests/conformance/tier3-parse.json +176 -0
  15. stackone_defender-0.9.0/tests/conformance/tier3-policy.json +1742 -0
  16. stackone_defender-0.9.0/tests/conformance/tier3-prompt.json +342 -0
  17. stackone_defender-0.9.0/tests/conformance/tier3-redact.json +242 -0
  18. stackone_defender-0.9.0/tests/conformance/tier3-serialize.json +3794 -0
  19. stackone_defender-0.9.0/tests/conformance_known_failures.txt +97 -0
  20. stackone_defender-0.9.0/tests/test_conformance.py +357 -0
  21. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/tests/test_onnx_classifier.py +3 -4
  22. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/tests/test_tier3.py +287 -14
  23. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/uv.lock +2 -2
  24. stackone_defender-0.8.2/.release-please-manifest.json +0 -1
  25. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/.gitignore +0 -0
  26. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/.python-version +0 -0
  27. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/.release-please-config.json +0 -0
  28. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/LICENSE +0 -0
  29. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/classifiers/__init__.py +0 -0
  30. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/classifiers/onnx_classifier.py +0 -0
  31. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/classifiers/pattern_detector.py +0 -0
  32. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/classifiers/patterns.py +0 -0
  33. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/classifiers/tier2_classifier.py +0 -0
  34. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/classifiers/tier3_orchestrator.py +0 -0
  35. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/config.py +0 -0
  36. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/core/__init__.py +0 -0
  37. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/core/sentence_cleaner.py +0 -0
  38. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/core/tool_result_sanitizer.py +0 -0
  39. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/models/minilm-multihead-v5/classifier_config.json +0 -0
  40. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/models/minilm-multihead-v5/config.json +0 -0
  41. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/models/minilm-multihead-v5/model_quantized.onnx +0 -0
  42. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/models/minilm-multihead-v5/tokenizer.json +0 -0
  43. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/models/minilm-multihead-v5/tokenizer_config.json +0 -0
  44. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/py.typed +0 -0
  45. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/sanitizers/__init__.py +0 -0
  46. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/sanitizers/encoding_detector.py +0 -0
  47. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/sanitizers/leet_normalizer.py +0 -0
  48. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/sanitizers/normalizer.py +0 -0
  49. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/sanitizers/role_stripper.py +0 -0
  50. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/sfe/__init__.py +0 -0
  51. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/sfe/model.ftz +0 -0
  52. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/sfe/preprocess.py +0 -0
  53. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/utils/__init__.py +0 -0
  54. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/utils/boundary.py +0 -0
  55. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/utils/field_detection.py +0 -0
  56. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/utils/structure.py +0 -0
  57. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/tests/__init__.py +0 -0
  58. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/tests/test_integration.py +0 -0
  59. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/tests/test_pattern_detector.py +0 -0
  60. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/tests/test_sanitizers.py +0 -0
  61. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/tests/test_sentence_cleaner.py +0 -0
  62. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/tests/test_sfe.py +0 -0
  63. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/tests/test_tier2_classifier.py +0 -0
  64. {stackone_defender-0.8.2 → stackone_defender-0.9.0}/tests/test_utils.py +0 -0
@@ -0,0 +1 @@
1
+ {".":"0.9.0"}
@@ -1,5 +1,17 @@
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
+
3
15
  ## [0.8.2](https://github.com/StackOneHQ/defender-py/compare/stackone-defender-v0.7.4...stackone-defender-v0.8.2) (2026-08-19)
4
16
 
5
17
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: stackone-defender
3
- Version: 0.8.2
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
@@ -179,12 +179,27 @@ defense = create_prompt_defense(
179
179
  tier3={
180
180
  "provider": MyProvider(), # optional: overrides the registry for this instance
181
181
  "escalation_band": {"lower": 0.3, "upper": 0.85}, # cascade gray band; [lower, upper), defaults shown
182
- "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"
183
185
  "block_threshold": 0.622, # optional; decide on score, not the model's word
184
186
  },
185
187
  )
186
188
  ```
187
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
+
188
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.
189
204
 
190
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.
@@ -257,7 +272,8 @@ class DefenseResult:
257
272
  cold_load: bool | None = None
258
273
  # Operational signals
259
274
  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 coverage was capped
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
261
277
  ```
262
278
 
263
279
  ### `defense.defend_tool_results(items)`
@@ -337,6 +353,25 @@ uv sync --group dev
337
353
  uv run pytest
338
354
  ```
339
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
+
340
375
  ## License
341
376
 
342
377
  Apache-2.0 — see [LICENSE](./LICENSE).
@@ -153,12 +153,27 @@ defense = create_prompt_defense(
153
153
  tier3={
154
154
  "provider": MyProvider(), # optional: overrides the registry for this instance
155
155
  "escalation_band": {"lower": 0.3, "upper": 0.85}, # cascade gray band; [lower, upper), defaults shown
156
- "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"
157
159
  "block_threshold": 0.622, # optional; decide on score, not the model's word
158
160
  },
159
161
  )
160
162
  ```
161
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
+
162
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.
163
178
 
164
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.
@@ -231,7 +246,8 @@ class DefenseResult:
231
246
  cold_load: bool | None = None
232
247
  # Operational signals
233
248
  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 coverage was capped
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
235
251
  ```
236
252
 
237
253
  ### `defense.defend_tool_results(items)`
@@ -311,6 +327,25 @@ uv sync --group dev
311
327
  uv run pytest
312
328
  ```
313
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
+
314
349
  ## License
315
350
 
316
351
  Apache-2.0 — see [LICENSE](./LICENSE).
Binary file
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "stackone-defender"
3
- version = "0.8.2"
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",
@@ -29,6 +29,7 @@ from ..types import (
29
29
  RiskLevel,
30
30
  Tier1Result,
31
31
  Tier2Stats,
32
+ Tier3ChunkSummary,
32
33
  Tier3EscalationBand,
33
34
  Tier3Provider,
34
35
  Tier3Result,
@@ -38,6 +39,7 @@ from ..types import (
38
39
  )
39
40
  from ..utils.boundary import generate_data_boundary
40
41
  from .sentence_cleaner import clean_high_risk_content
42
+ from .tier3_input import DEFAULT_MAX_CHUNKS, chunk_tier3_input, content_budget, format_records_for_tier3
41
43
  from .tool_result_sanitizer import ToolResultSanitizer, create_tool_result_sanitizer
42
44
 
43
45
  _logger = logging.getLogger(__name__)
@@ -48,6 +50,7 @@ _tier2_unavailable_warned = False
48
50
 
49
51
  _DEFAULT_TIER3_BAND = Tier3EscalationBand(lower=0.3, upper=0.85)
50
52
  _DEFAULT_TIER3_MAX_TEXT_LENGTH = 10000
53
+ _TIER3_ON_OVERSIZE = ("skip", "block", "scan_anyway")
51
54
 
52
55
 
53
56
  @dataclass
@@ -154,27 +157,12 @@ def _extract_strings(
154
157
  return strings
155
158
 
156
159
 
157
- def _bounded_join_strings(strings: list[str], max_len: int, sep: str = "\n") -> str:
158
- """Join strings with ``sep``, capping total length at ``max_len`` without building the full join first."""
159
- if max_len <= 0:
160
- return ""
161
- parts: list[str] = []
162
- used = 0
163
- sep_len = len(sep)
164
- for s in strings:
165
- if not s:
166
- continue
167
- prefix = sep_len if parts else 0
168
- if used + prefix >= max_len:
169
- break
170
- remaining = max_len - used - prefix
171
- if len(s) <= remaining:
172
- parts.append(s)
173
- used += prefix + len(s)
174
- else:
175
- parts.append(s[:remaining])
176
- break
177
- return sep.join(parts)
160
+ def _finite_at_least_one(v: Any) -> bool:
161
+ if isinstance(v, bool) or not isinstance(v, (int, float)):
162
+ return False
163
+ if isinstance(v, int):
164
+ return v >= 1 # math.isfinite overflows on huge ints
165
+ return math.isfinite(v) and math.floor(v) >= 1
178
166
 
179
167
 
180
168
  _RISK_LEVELS: list[RiskLevel] = ["low", "medium", "high", "critical"]
@@ -265,20 +253,44 @@ class PromptDefense:
265
253
  self._tier3_missing_provider_warned = False
266
254
  self._tier3_block_threshold: float | None = None
267
255
  self._tier3_missing_score_warned = False
256
+ self._tier3_on_oversize = "skip"
257
+ self._tier3_max_chunks = DEFAULT_MAX_CHUNKS
268
258
  tier3_opts = tier3 or {}
269
259
  if tier3_opts.get("provider") is not None:
270
260
  self._tier3_custom_provider = tier3_opts["provider"]
271
261
  max_text_length = tier3_opts.get("max_text_length", tier3_opts.get("maxTextLength"))
272
262
  if max_text_length is not None:
273
- if isinstance(max_text_length, (int, float)) and math.isfinite(max_text_length) and max_text_length > 0:
274
- self._tier3_max_text_length = int(max_text_length)
263
+ # Validate the floored value: a cap in (0, 1) floors to 0, which would disable Tier 3.
264
+ if _finite_at_least_one(max_text_length):
265
+ self._tier3_max_text_length = math.floor(max_text_length)
275
266
  else:
276
267
  _logger.warning(
277
- "[defender] invalid tier3.max_text_length %s — must be a positive finite number. "
268
+ "[defender] invalid tier3.max_text_length %s — must be a finite number >= 1. "
278
269
  "Falling back to default %s.",
279
270
  max_text_length,
280
271
  _DEFAULT_TIER3_MAX_TEXT_LENGTH,
281
272
  )
273
+ on_oversize = tier3_opts.get("on_oversize", tier3_opts.get("onOversize"))
274
+ if on_oversize is not None:
275
+ if on_oversize in _TIER3_ON_OVERSIZE:
276
+ self._tier3_on_oversize = on_oversize
277
+ else:
278
+ _logger.warning(
279
+ '[defender] invalid tier3.on_oversize %r — must be "skip", "block", or "scan_anyway". '
280
+ 'Falling back to default "skip".',
281
+ on_oversize,
282
+ )
283
+ max_chunks = tier3_opts.get("max_chunks", tier3_opts.get("maxChunks"))
284
+ if max_chunks is not None:
285
+ if _finite_at_least_one(max_chunks):
286
+ self._tier3_max_chunks = math.floor(max_chunks)
287
+ else:
288
+ _logger.warning(
289
+ "[defender] invalid tier3.max_chunks %s — must be a finite number >= 1. "
290
+ "Falling back to default %s.",
291
+ max_chunks,
292
+ DEFAULT_MAX_CHUNKS,
293
+ )
282
294
  escalation_band = tier3_opts.get("escalation_band", tier3_opts.get("escalationBand"))
283
295
  if escalation_band is not None:
284
296
  lower = escalation_band.get("lower")
@@ -392,7 +404,10 @@ class PromptDefense:
392
404
  def _validate_tier3_verdict(verdict: Any) -> Tier3Verdict | Tier3Skip:
393
405
  if isinstance(verdict, Tier3Verdict):
394
406
  if verdict.decision in ("block", "allow"):
395
- return replace(verdict, score=PromptDefense._normalize_tier3_score(verdict.score))
407
+ # errored/note are set by the defender, never by a provider.
408
+ return replace(
409
+ verdict, score=PromptDefense._normalize_tier3_score(verdict.score), errored=None, note=None
410
+ )
396
411
  return Tier3Skip(
397
412
  skip_reason=(
398
413
  f'Tier 3 provider returned invalid decision: {verdict.decision!r} '
@@ -533,55 +548,179 @@ class PromptDefense:
533
548
  )
534
549
  return risk_level, allowed
535
550
 
551
+ async def _review_tier3_chunk(self, provider: Tier3Provider, text: str, tool_name: str) -> Tier3Result:
552
+ try:
553
+ raw = await self._invoke_tier3_classify(provider, text, tool_name)
554
+ except asyncio.CancelledError as e:
555
+ # Our own task being cancelled must propagate; a provider's internal cancel is a chunk failure.
556
+ task = asyncio.current_task()
557
+ if task is not None and task.cancelling():
558
+ raise
559
+ return Tier3Skip(skip_reason=f"Tier 3 provider error: cancelled {e}".rstrip())
560
+ except Exception as e:
561
+ return Tier3Skip(skip_reason=f"Tier 3 provider error: {e}")
562
+ return self._validate_tier3_verdict(raw)
563
+
536
564
  async def _run_tier3_only(
537
565
  self,
538
566
  value: Any,
539
567
  provider: Tier3Provider,
540
568
  tool_name: str,
541
- depth_flag: dict[str, bool],
569
+ depth_flag: dict[str, Any],
542
570
  start_time: float,
543
571
  ) -> DefenseResult:
544
- strings = [s for s in _extract_strings(value, None, depth_flag) if len(s) > 0]
545
- bounded = _bounded_join_strings(strings, self._tier3_max_text_length)
546
-
572
+ # ENG-2455: record-oriented `field: value` input, so bare values keep field context.
573
+ # ENG-1339: serialize up to a coverage ceiling, chunk it, and review every chunk in parallel
574
+ # (union of blocks) instead of truncating: a fixed truncation point is easy to evade.
547
575
  verdict: Tier3Verdict | None = None
548
576
  skip_reason: str | None = None
549
- if len(bounded) == 0:
550
- skip_reason = "No strings extracted from tool result"
577
+ # A policy block (on_oversize), as opposed to a chunk verdict block.
578
+ oversize_block = False
579
+ chunk_summary: Tier3ChunkSummary | None = None
580
+ # The payload couldn't be serialized: un-analyzable input fails closed in strict mode.
581
+ payload_error = False
582
+ chunk_failures = 0
583
+ chunk_total = 0
584
+ first_chunk_failure: str | None = None
585
+
586
+ per_chunk = self._tier3_max_text_length
587
+ # The ceiling uses the per-chunk content budget (minus the carried overlap), so the content
588
+ # typically fits in max_chunks chunks; the reviewer still sees <= per_chunk chars per call.
589
+ ceiling = self._tier3_max_chunks * content_budget(per_chunk)
590
+ joined = ""
591
+ try:
592
+ joined = format_records_for_tier3(value, depth_flag, ceiling, self._config.traversal.max_size)
593
+ except Exception as e:
594
+ payload_error = True
595
+ skip_reason = f"Tier 3 serialization error: {e}"
596
+ # A depth cut also hides content from the reviewer, so it counts as oversize.
597
+ oversize = depth_flag.get("budget_exceeded") is True or depth_flag["hit"] is True
598
+
599
+ if payload_error:
600
+ pass
601
+ elif oversize and self._tier3_on_oversize == "block":
602
+ # Checked before the empty case so "block" fails closed even when nothing fit.
603
+ oversize_block = True
604
+ depth_flag["coverage_degraded"] = True
605
+ skip_reason = (
606
+ f"Tool result too large to fully review (> {self._tier3_max_chunks} chunks) — blocked by onOversize"
607
+ )
608
+ elif not joined:
609
+ if oversize:
610
+ depth_flag["coverage_degraded"] = True
611
+ if oversize and self._tier3_on_oversize == "scan_anyway":
612
+ oversize_block = True
613
+ skip_reason = "Tool result too large to review — no content fit within the ceiling"
614
+ elif skip_reason is None:
615
+ skip_reason = (
616
+ "Tool result too large to review — no content fit within the ceiling"
617
+ if oversize
618
+ else "No reviewable string content emitted from tool result"
619
+ )
551
620
  else:
552
- try:
553
- raw = await self._invoke_tier3_classify(provider, bounded, tool_name)
554
- validated = self._validate_tier3_verdict(raw)
555
- if isinstance(validated, Tier3Skip):
556
- skip_reason = validated.skip_reason
621
+ # Always review what fit, so a fitting injection is never dropped; on_oversize governs
622
+ # only the overflow.
623
+ chunks = chunk_tier3_input(joined, per_chunk)
624
+ results = await asyncio.gather(*(self._review_tier3_chunk(provider, c, tool_name) for c in chunks))
625
+ blocked_count = 0
626
+ blocking_chunk: int | None = None
627
+ reviewed = 0
628
+ for idx, res in enumerate(results):
629
+ if isinstance(res, Tier3Skip):
630
+ chunk_failures += 1
631
+ if first_chunk_failure is None:
632
+ first_chunk_failure = res.skip_reason
633
+ continue
634
+ reviewed += 1
635
+ is_block = self._is_tier3_block(res)
636
+ if is_block:
637
+ blocked_count += 1
638
+ if blocking_chunk is None:
639
+ blocking_chunk = idx
640
+ # Representative: a blocker if any, else the highest-score allow.
641
+ if verdict is None:
642
+ verdict = res
557
643
  else:
558
- verdict = validated
559
- except Exception as e:
560
- skip_reason = f"Tier 3 provider error: {e}"
561
-
562
- sanitized = self._tool_sanitizer.sanitize(value, tool_name=tool_name)
563
- detections, _tier1_flagged, prm = self._tier1_metadata(sanitized)
564
-
565
- blocked = verdict is not None and self._is_tier3_block(verdict)
566
- risk_level: RiskLevel = "high" if blocked else "low"
567
- allowed = not self._config.block_high_risk or not blocked
568
- tier3_result: Tier3Result = (
569
- verdict if verdict is not None else Tier3Skip(skip_reason=skip_reason or "Tier 3 skipped")
570
- )
644
+ rep_block = self._is_tier3_block(verdict)
645
+ if (is_block and not rep_block) or (
646
+ is_block == rep_block and (res.score or 0) > (verdict.score or 0)
647
+ ):
648
+ verdict = res
649
+ chunk_total = len(chunks)
650
+ chunk_summary = Tier3ChunkSummary(
651
+ chunks=chunk_total,
652
+ blocked=blocked_count,
653
+ blocking_chunk=blocking_chunk,
654
+ oversize=True if oversize else None,
655
+ )
656
+ if reviewed == 0:
657
+ # Every chunk failed: provider outage or bad verdicts. Fails open.
658
+ if skip_reason is None:
659
+ skip_reason = first_chunk_failure or "Tier 3 skipped: no chunk produced a verdict"
660
+ depth_flag["coverage_degraded"] = True
661
+ elif chunk_failures > 0:
662
+ depth_flag["coverage_degraded"] = True
663
+ if oversize:
664
+ depth_flag["coverage_degraded"] = True
665
+ # The overflow went unreviewed: scan_anyway fails closed on it unless a chunk blocked.
666
+ if not (verdict is not None and self._is_tier3_block(verdict)) and self._tier3_on_oversize == "scan_anyway":
667
+ oversize_block = True
668
+ skip_reason = f"Tool result exceeds review ceiling; {chunk_total} chunks scanned, overflow unreviewed"
669
+
670
+ # Tier 1 runs for `detections` metadata only (the LLM decides in tier3_only). Its walk is
671
+ # uncapped, so a failure here degrades coverage but doesn't override a verdict.
672
+ patterns_by_field: dict[str, list[str]] = {}
673
+ detections: list[str] = []
674
+ sanitized_content: Any = value
675
+ analysis_degraded = False
676
+ try:
677
+ sanitized = self._tool_sanitizer.sanitize(value, tool_name=tool_name)
678
+ detections, _tier1_flagged, patterns_by_field = self._tier1_metadata(sanitized)
679
+ sanitized_content = sanitized.sanitized
680
+ analysis_degraded = bool(self._coverage_degraded(sanitized.metadata, {"hit": False}))
681
+ except Exception as e:
682
+ analysis_degraded = True
683
+ if skip_reason is None:
684
+ skip_reason = f"Tier 1 metadata error: {e}"
685
+
686
+ blocked = oversize_block or (verdict is not None and self._is_tier3_block(verdict))
687
+ risk_level: RiskLevel = "high" if blocked or payload_error else "low"
688
+ # block_high_risk=False always allows; strict mode fails closed on a payload error.
689
+ allowed = not self._config.block_high_risk or (not blocked and not payload_error)
690
+
691
+ errored = True if chunk_failures > 0 else None
692
+ tier3_result: Tier3Result
693
+ if verdict is not None:
694
+ # skip_reason isn't reported with a verdict, so say why the outcome isn't the verdict's alone.
695
+ notes: list[str] = []
696
+ if oversize_block and skip_reason:
697
+ notes.append(skip_reason)
698
+ if chunk_failures > 0:
699
+ failed = f"{chunk_failures} of {chunk_total} chunks failed"
700
+ notes.append(f"{failed}: {first_chunk_failure}" if first_chunk_failure else failed)
701
+ tier3_result = replace(verdict, errored=errored, note="; ".join(notes) or None)
702
+ else:
703
+ tier3_result = Tier3Skip(skip_reason=skip_reason or "Tier 3 skipped", errored=errored)
571
704
 
572
705
  return DefenseResult(
573
706
  allowed=allowed,
574
707
  risk_level=risk_level,
575
- sanitized=sanitized.sanitized,
708
+ sanitized=sanitized_content,
576
709
  detections=detections,
577
710
  fields_sanitized=[],
578
- patterns_by_field=prm,
579
- detected_field_count=len(prm),
711
+ patterns_by_field=patterns_by_field,
712
+ detected_field_count=len(patterns_by_field),
580
713
  tier3=tier3_result,
581
714
  fields_dropped=[],
715
+ # The depth-limit signal only; budget drops set coverage_degraded.
582
716
  truncated_at_depth=depth_flag["hit"] or None,
583
717
  latency_ms=(time.perf_counter() - start_time) * 1000,
584
- coverage_degraded=self._coverage_degraded(sanitized.metadata, depth_flag),
718
+ coverage_degraded=(
719
+ True
720
+ if depth_flag["hit"] or depth_flag.get("coverage_degraded") or analysis_degraded or payload_error
721
+ else None
722
+ ),
723
+ tier3_chunk_summary=chunk_summary,
585
724
  )
586
725
 
587
726
  def defend_tool_result(self, value: Any, tool_name: str) -> DefenseResult: