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.
- stackone_defender-0.9.0/.release-please-manifest.json +1 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/CHANGELOG.md +12 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/PKG-INFO +38 -3
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/README.md +37 -2
- stackone_defender-0.9.0/prof.out +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/pyproject.toml +1 -1
- stackone_defender-0.9.0/scripts/sync_conformance.sh +47 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/__init__.py +2 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/core/prompt_defense.py +192 -53
- stackone_defender-0.9.0/src/stackone_defender/core/tier3_input.py +634 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/types.py +21 -1
- stackone_defender-0.9.0/tests/conformance/README.md +82 -0
- stackone_defender-0.9.0/tests/conformance/VERSION +1 -0
- stackone_defender-0.9.0/tests/conformance/tier3-parse.json +176 -0
- stackone_defender-0.9.0/tests/conformance/tier3-policy.json +1742 -0
- stackone_defender-0.9.0/tests/conformance/tier3-prompt.json +342 -0
- stackone_defender-0.9.0/tests/conformance/tier3-redact.json +242 -0
- stackone_defender-0.9.0/tests/conformance/tier3-serialize.json +3794 -0
- stackone_defender-0.9.0/tests/conformance_known_failures.txt +97 -0
- stackone_defender-0.9.0/tests/test_conformance.py +357 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/tests/test_onnx_classifier.py +3 -4
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/tests/test_tier3.py +287 -14
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/uv.lock +2 -2
- stackone_defender-0.8.2/.release-please-manifest.json +0 -1
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/.gitignore +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/.python-version +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/.release-please-config.json +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/LICENSE +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/classifiers/__init__.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/classifiers/onnx_classifier.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/classifiers/pattern_detector.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/classifiers/patterns.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/classifiers/tier2_classifier.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/classifiers/tier3_orchestrator.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/config.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/core/__init__.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/core/sentence_cleaner.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/core/tool_result_sanitizer.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/models/minilm-multihead-v5/classifier_config.json +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/models/minilm-multihead-v5/config.json +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/models/minilm-multihead-v5/model_quantized.onnx +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/models/minilm-multihead-v5/tokenizer.json +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/models/minilm-multihead-v5/tokenizer_config.json +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/py.typed +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/sanitizers/__init__.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/sanitizers/encoding_detector.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/sanitizers/leet_normalizer.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/sanitizers/normalizer.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/sanitizers/role_stripper.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/sfe/__init__.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/sfe/model.ftz +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/sfe/preprocess.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/utils/__init__.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/utils/boundary.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/utils/field_detection.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/utils/structure.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/tests/__init__.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/tests/test_integration.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/tests/test_pattern_detector.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/tests/test_sanitizers.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/tests/test_sentence_cleaner.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/tests/test_sfe.py +0 -0
- {stackone_defender-0.8.2 → stackone_defender-0.9.0}/tests/test_tier2_classifier.py +0 -0
- {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.
|
|
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, #
|
|
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
|
|
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, #
|
|
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
|
|
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
|
|
@@ -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",
|
{stackone_defender-0.8.2 → stackone_defender-0.9.0}/src/stackone_defender/core/prompt_defense.py
RENAMED
|
@@ -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
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
274
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
569
|
+
depth_flag: dict[str, Any],
|
|
542
570
|
start_time: float,
|
|
543
571
|
) -> DefenseResult:
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
|
|
550
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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=
|
|
708
|
+
sanitized=sanitized_content,
|
|
576
709
|
detections=detections,
|
|
577
710
|
fields_sanitized=[],
|
|
578
|
-
patterns_by_field=
|
|
579
|
-
detected_field_count=len(
|
|
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=
|
|
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:
|