Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{".":"0.1.2"}
{".":"0.6.1"}
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
# Changelog

## [0.6.1](https://github.com/StackOneHQ/stackone-defender/compare/stackone-defender-v0.1.2...stackone-defender-v0.6.1) (2026-04-21)

### Features

* align Python package behavior with `@stackone/defender` 0.6.1
* add SFE preprocessing support (`use_sfe`) with fail-open optional runtime loading
* add packed-chunk Tier 2 batching and density-adjusted scoring
* add dangerous-key traversal hardening (`__proto__`, `constructor`, `prototype`)
* add cumulative-risk fractional thresholds to reduce list-response false positives

### Bug Fixes

* use `fasttext-ng` instead of `fasttext-wheel` for the `[sfe]` extra and dev tests so Python 3.13 CI can install maintained FastText bindings (NumPy 2.3+).

### Breaking Changes

* Python package version jumps from `0.1.2` to `0.6.1` to align release train with TypeScript parity.
* `DefenseResult` now includes `fields_dropped` and `truncated_at_depth`.

## [0.1.2](https://github.com/StackOneHQ/stackone-defender/compare/stackone-defender-v0.1.1...stackone-defender-v0.1.2) (2026-04-08)


Expand Down
26 changes: 23 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,15 @@ pip install stackone-defender[onnx]

The ONNX model (~22MB) is bundled in the wheel — no extra downloads at runtime.

**SFE preprocessor (optional)** — add extras:

```bash
pip install stackone-defender[sfe]
# or: uv add "stackone-defender[sfe]"
```

The `[sfe]` extra installs [`fasttext-ng`](https://pypi.org/project/fasttext-ng/) (provides the `fasttext` module). It requires **NumPy 2.3+**. PyPI may ship a wheel only for some platforms; otherwise pip/uv builds from source (needs a C++ toolchain).

## Quick start

```python
Expand Down Expand Up @@ -89,11 +98,17 @@ else:

### Tier 2 — ML classification (ONNX)

Sentence-level MiniLM classifier (int8 ONNX ~22 MB, bundled):
Packed-chunk MiniLM classifier (int8 ONNX ~22 MB, bundled):

- Split text into sentences, score each (0.0 = benign, 1.0 = injection-like), take the max
- Split text into sentences, pack to model-sized chunks, score chunks in batched ONNX calls
- Catches paraphrased or novel injections missed by regex
- Roughly ~10 ms per batch after warmup (CPU)
- Uses chunked batch inference to bound memory on large payloads

### Optional SFE preprocessor

- `use_sfe=True` enables a field-level FastText pass before Tier 1/Tier 2
- Drops metadata-like leaves (IDs, enum-like strings) and keeps user-facing content
- Fails open if the runtime/model is unavailable: payload continues unfiltered

**Benchmarks** (F1 @ threshold 0.5):

Expand Down Expand Up @@ -126,6 +141,7 @@ defense = create_prompt_defense(
block_high_risk=False,
default_risk_level="medium",
tier2_fields=["subject", "body", "snippet"], # optional: scope Tier 2 to these JSON keys
use_sfe=True, # optional: enable semantic field extractor preprocessing
config={
"tier2": {
"high_risk_threshold": 0.8,
Expand All @@ -140,6 +156,8 @@ defense = create_prompt_defense(
Runs Tier 1 sanitization on risky fields, then Tier 2 on extracted text (with optional field scoping). **Synchronous** — no `await`.

```python
from dataclasses import dataclass, field

@dataclass
class DefenseResult:
allowed: bool
Expand All @@ -151,6 +169,8 @@ class DefenseResult:
tier2_score: float | None = None
tier2_skip_reason: str | None = None
max_sentence: str | None = None
fields_dropped: list[str] = field(default_factory=list)
truncated_at_depth: bool | None = None
latency_ms: float = 0.0
```

Expand Down
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "stackone-defender"
version = "0.1.2"
version = "0.6.1"
description = "Indirect prompt injection defense for AI agents using tool calls"
readme = "README.md"
requires-python = ">=3.11"
Expand All @@ -25,13 +25,17 @@ Repository = "https://github.com/StackOneHQ/stackone-defender"

[project.optional-dependencies]
onnx = ["onnxruntime>=1.16.0", "tokenizers>=0.15.0", "numpy>=1.24.0"]
# fasttext-ng provides the `fasttext` module (maintained bindings; supports 3.13).
# Pulls numpy>=2.3; SFE still fail-opens when import/load fails.
sfe = ["fasttext-ng>=0.9.3"]

[dependency-groups]
dev = [
"pytest>=8.0",
"onnxruntime>=1.16.0",
"tokenizers>=0.15.0",
"numpy>=1.24.0",
"fasttext-ng>=0.9.3",
]

[build-system]
Expand Down
14 changes: 14 additions & 0 deletions src/stackone_defender/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,26 @@
"""

from .core.prompt_defense import PromptDefense, create_prompt_defense
from .sfe.preprocess import (
DropDecision,
SfePredictor,
SfePreprocessResult,
get_default_predictor,
get_default_sfe_model_path,
sfe_preprocess,
)
from .types import DefenseResult, RiskLevel, Tier1Result

__all__ = [
"DefenseResult",
"DropDecision",
"PromptDefense",
"RiskLevel",
"SfePredictor",
"SfePreprocessResult",
"Tier1Result",
"create_prompt_defense",
"get_default_predictor",
"get_default_sfe_model_path",
"sfe_preprocess",
]
20 changes: 19 additions & 1 deletion src/stackone_defender/classifiers/onnx_classifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ def _sigmoid(x: float) -> float:
class OnnxClassifier:
"""ONNX Classifier for fine-tuned MiniLM models."""

_MAX_BATCH_CHUNK = 32

def __init__(self, model_path: str | None = None):
self._model_path = model_path or _default_model_path()
self._session = None
Expand Down Expand Up @@ -105,10 +107,17 @@ def classify(self, text: str) -> float:
return _sigmoid(logit)

def classify_batch(self, texts: list[str]) -> list[float]:
"""Classify multiple texts in batch."""
"""Classify multiple texts in batch, bounded by chunk size."""
if not texts:
return []
self._ensure_loaded()
all_scores: list[float] = []
for offset in range(0, len(texts), self._MAX_BATCH_CHUNK):
chunk = texts[offset: offset + self._MAX_BATCH_CHUNK]
all_scores.extend(self._classify_batch_chunk(chunk))
return all_scores

def _classify_batch_chunk(self, texts: list[str]) -> list[float]:
import numpy as np

encodings = self._tokenizer.encode_batch(texts)
Expand All @@ -119,6 +128,15 @@ def classify_batch(self, texts: list[str]) -> list[float]:
logits = results[0]
return [_sigmoid(float(logits[i][0])) for i in range(len(texts))]

def count_tokens(self, text: str) -> int:
self._ensure_loaded()
encoding = self._tokenizer.encode(text)
# Padding is enabled at a fixed length; count only real (attended) tokens.
return int(sum(encoding.attention_mask))

def get_max_length(self) -> int:
return self._max_length

def warmup(self) -> None:
self.load_model()

Expand Down
Loading
Loading