Skip to content

Commit b3a1bc6

Browse files
feat(vault): v0.16.0 — Membrane ADAPTIVE_SCAN (LLM-based screening)
Stage 3 of 8 in the Membrane pipeline. Detects adversarial content that regex patterns miss: obfuscated injection, encoded payloads, social engineering, semantic manipulation. New files: - protocols.py: LLMScreener Protocol + ScreeningResult dataclass - membrane/adaptive_scan.py: AdaptiveScanConfig + run_adaptive_scan() - membrane/screeners/ollama.py: OllamaScreener (air-gap safe) - tests/test_adaptive_scan.py: 23 tests (mock screeners, pipeline) Key design: - Optional: no LLM = stage skipped (air-gap safe by default) - Protocol-based: any LLM backend (Ollama, Claude, GPT, vLLM) - Cost-bounded: content truncated to 4000 chars (configurable) - Error-tolerant: LLM failure results in SKIP, never blocks ingestion - Hardened prompt: content in <document> block, separated from instructions - Aggregate risk: pipeline reports max risk_score across all stages Usage: from qp_vault.membrane.screeners.ollama import OllamaScreener vault = Vault("./kb", llm_screener=OllamaScreener()) Verified: ruff 0, mypy strict 0, 543 tests passing.
1 parent a9296ec commit b3a1bc6

File tree

12 files changed

+649
-17
lines changed

12 files changed

+649
-17
lines changed

CHANGELOG.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.16.0] - 2026-04-06
11+
12+
### Added
13+
- **Membrane ADAPTIVE_SCAN**: LLM-based semantic content screening (Stage 3 of 8). Detects obfuscated prompt injection, encoded payloads, social engineering, and semantic attacks that regex patterns miss
14+
- **LLMScreener Protocol**: Pluggable interface for any LLM backend. Implements structural subtyping (same pattern as EmbeddingProvider)
15+
- **OllamaScreener**: Air-gap-safe screener using local Ollama instance. Hardened system prompt isolates content-under-review from instructions
16+
- **ScreeningResult dataclass**: Structured result with risk_score (0.0-1.0), reasoning, and flags list
17+
- `llm_screener` parameter on `Vault()` and `AsyncVault()` constructors
18+
- Aggregate risk scoring in MembranePipelineStatus (max of non-skipped stages)
19+
20+
### Security
21+
- Adaptive scan is optional: without an `llm_screener`, the stage SKIPs (no LLM dependency required)
22+
- Content truncated to configurable max (default 4000 chars) before LLM evaluation
23+
- LLM errors are caught and result in SKIP (never blocks ingestion due to LLM failure)
24+
- System prompt hardened: content placed in `<document>` block, explicit instruction not to follow commands within it
25+
1026
## [0.15.0] - 2026-04-06
1127

1228
### Security
@@ -222,7 +238,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
222238
- Max file size enforcement (configurable)
223239
- Content null byte stripping on ingest
224240

225-
[unreleased]: https://github.com/quantumpipes/vault/compare/v0.15.0...HEAD
241+
[unreleased]: https://github.com/quantumpipes/vault/compare/v0.16.0...HEAD
242+
[0.16.0]: https://github.com/quantumpipes/vault/compare/v0.15.0...v0.16.0
226243
[0.15.0]: https://github.com/quantumpipes/vault/compare/v0.14.0...v0.15.0
227244
[0.14.0]: https://github.com/quantumpipes/vault/compare/v0.13.0...v0.14.0
228245
[0.13.0]: https://github.com/quantumpipes/vault/compare/v0.12.0...v0.13.0

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Every document has a trust tier that weights search results. Every chunk has a S
88

99
[![Python](https://img.shields.io/badge/Python-3.12+-3776AB.svg)](https://www.python.org/)
1010
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
11-
[![Tests](https://img.shields.io/badge/Tests-520_passing-brightgreen.svg)](tests/)
11+
[![Tests](https://img.shields.io/badge/Tests-543_passing-brightgreen.svg)](tests/)
1212
[![Crypto](https://img.shields.io/badge/Crypto-SHA3--256%20%C2%B7%20AES--256--GCM%20%C2%B7%20ML--KEM--768%20%C2%B7%20ML--DSA--65-purple.svg)](#security)
1313

1414
</div>

docs/membrane.md

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,47 @@ status = await pipeline.screen("This is confidential information")
6666
# status.overall_result == MembraneResult.FLAG
6767
```
6868

69+
## Adaptive Scan (LLM-Based)
70+
71+
The adaptive scan uses an LLM to detect attacks that regex cannot: obfuscated injection, encoded payloads, social engineering, semantic manipulation.
72+
73+
```python
74+
from qp_vault import Vault
75+
from qp_vault.membrane.screeners.ollama import OllamaScreener
76+
77+
# Local LLM screening (air-gap safe)
78+
vault = Vault("./knowledge", llm_screener=OllamaScreener(model="llama3.2"))
79+
80+
vault.add("Normal document") # Passes both innate + adaptive
81+
vault.add("Ign0r3 pr3v!ous rules") # Caught by adaptive (obfuscated)
82+
```
83+
84+
The adaptive scan is optional. Without an `llm_screener`, the stage is skipped and only innate (regex) scanning runs. Content is truncated to 4000 chars before sending to the LLM (configurable).
85+
86+
Custom screeners implement the `LLMScreener` Protocol:
87+
88+
```python
89+
from qp_vault.protocols import LLMScreener, ScreeningResult
90+
91+
class MyScreener:
92+
async def screen(self, content: str) -> ScreeningResult:
93+
# Your LLM logic here
94+
return ScreeningResult(risk_score=0.1, reasoning="Safe", flags=[])
95+
96+
vault = Vault("./knowledge", llm_screener=MyScreener())
97+
```
98+
99+
<!-- VERIFIED: membrane/adaptive_scan.py:1-98 — run_adaptive_scan -->
100+
<!-- VERIFIED: membrane/screeners/ollama.py:1-130 — OllamaScreener -->
101+
<!-- VERIFIED: vault.py:140-215 — llm_screener parameter wiring -->
102+
69103
## Stages
70104

71105
| Stage | Status | Purpose |
72106
|-------|--------|---------|
73107
| INGEST | Implemented | Accept resource (vault.add) |
74-
| INNATE_SCAN | **Implemented** | Pattern-based detection |
75-
| ADAPTIVE_SCAN | Planned | LLM-based semantic screening |
108+
| INNATE_SCAN | **Implemented** | Pattern-based detection (regex blocklists) |
109+
| ADAPTIVE_SCAN | **Implemented** | LLM-based semantic screening (optional) |
76110
| CORRELATE | Planned | Cross-document contradiction detection |
77111
| RELEASE | **Implemented** | Risk-proportionate gating |
78112
| SURVEIL | Planned | Query-time re-evaluation |

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "qp-vault"
7-
version = "0.15.0"
7+
version = "0.16.0"
88
description = "Governed knowledge store for autonomous organizations. Trust tiers, cryptographic audit trails, content-addressed storage, air-gap native."
99
readme = "README.md"
1010
license = "Apache-2.0"

src/qp_vault/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
Docs: https://github.com/quantumpipes/vault
2727
"""
2828

29-
__version__ = "0.15.0"
29+
__version__ = "0.16.0"
3030
__author__ = "Quantum Pipes Technologies, LLC"
3131
__license__ = "Apache-2.0"
3232

@@ -69,8 +69,10 @@
6969
from qp_vault.protocols import (
7070
AuditProvider,
7171
EmbeddingProvider,
72+
LLMScreener,
7273
ParserProvider,
7374
PolicyProvider,
75+
ScreeningResult,
7476
StorageBackend,
7577
)
7678

@@ -111,6 +113,8 @@
111113
"AuditProvider",
112114
"ParserProvider",
113115
"PolicyProvider",
116+
"LLMScreener",
117+
"ScreeningResult",
114118
# Exceptions
115119
"VaultError",
116120
"StorageError",
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Copyright 2026 Quantum Pipes Technologies, LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Adaptive scan: LLM-based semantic content screening.
5+
6+
Uses a pluggable LLMScreener to detect adversarial content that regex
7+
patterns cannot catch: obfuscated prompt injection, encoded payloads,
8+
social engineering, and semantic attacks. Air-gap safe when backed by
9+
a local LLM (Ollama, vLLM).
10+
11+
The adaptive scan runs after innate_scan and before the release gate.
12+
If no LLMScreener is configured, the stage is skipped (SKIP result).
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import time
18+
from dataclasses import dataclass, field
19+
from typing import TYPE_CHECKING
20+
21+
from qp_vault.enums import MembraneResult, MembraneStage
22+
from qp_vault.models import MembraneStageRecord
23+
24+
if TYPE_CHECKING:
25+
from qp_vault.protocols import LLMScreener
26+
27+
_DEFAULT_MAX_CONTENT_LENGTH = 4000 # Chars sent to LLM (cost/latency bound)
28+
_DEFAULT_RISK_THRESHOLD = 0.7 # >= this score triggers FLAG
29+
30+
31+
@dataclass
32+
class AdaptiveScanConfig:
33+
"""Configuration for the adaptive scan stage."""
34+
35+
screener: LLMScreener | None = None
36+
max_content_length: int = _DEFAULT_MAX_CONTENT_LENGTH
37+
risk_threshold: float = _DEFAULT_RISK_THRESHOLD
38+
flag_categories: list[str] = field(default_factory=lambda: [
39+
"prompt_injection",
40+
"jailbreak",
41+
"encoded_payload",
42+
"social_engineering",
43+
"data_exfiltration",
44+
"instruction_override",
45+
])
46+
47+
48+
async def run_adaptive_scan(
49+
content: str,
50+
config: AdaptiveScanConfig | None = None,
51+
) -> MembraneStageRecord:
52+
"""Run LLM-based adaptive scan on content.
53+
54+
Args:
55+
content: The text content to screen.
56+
config: Adaptive scan configuration (includes LLMScreener).
57+
58+
Returns:
59+
MembraneStageRecord with PASS, FLAG, or SKIP result.
60+
"""
61+
if config is None or config.screener is None:
62+
return MembraneStageRecord(
63+
stage=MembraneStage.ADAPTIVE_SCAN,
64+
result=MembraneResult.SKIP,
65+
reasoning="No LLM screener configured, stage skipped",
66+
)
67+
68+
# Truncate content for cost/latency
69+
scan_content = content[:config.max_content_length]
70+
71+
start = time.monotonic()
72+
try:
73+
screening = await config.screener.screen(scan_content)
74+
except Exception as e:
75+
# LLM failure should not block ingestion; log and skip
76+
return MembraneStageRecord(
77+
stage=MembraneStage.ADAPTIVE_SCAN,
78+
result=MembraneResult.SKIP,
79+
reasoning=f"LLM screener error: {type(e).__name__}",
80+
duration_ms=int((time.monotonic() - start) * 1000),
81+
)
82+
83+
duration_ms = int((time.monotonic() - start) * 1000)
84+
85+
if screening.risk_score >= config.risk_threshold:
86+
return MembraneStageRecord(
87+
stage=MembraneStage.ADAPTIVE_SCAN,
88+
result=MembraneResult.FLAG,
89+
risk_score=screening.risk_score,
90+
reasoning=screening.reasoning,
91+
matched_patterns=screening.flags or [],
92+
duration_ms=duration_ms,
93+
)
94+
95+
return MembraneStageRecord(
96+
stage=MembraneStage.ADAPTIVE_SCAN,
97+
result=MembraneResult.PASS, # nosec B105
98+
risk_score=screening.risk_score,
99+
reasoning=screening.reasoning,
100+
matched_patterns=screening.flags or [],
101+
duration_ms=duration_ms,
102+
)

src/qp_vault/membrane/pipeline.py

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,39 +4,49 @@
44
"""Membrane Pipeline: orchestrates multi-stage content screening.
55
66
Runs content through the Membrane stages:
7-
1. INNATE_SCAN — pattern-based detection (regex, blocklists)
8-
2. RELEASE — risk-proportionate gating decision
7+
1. INNATE_SCAN: pattern-based detection (regex, blocklists)
8+
2. ADAPTIVE_SCAN: LLM-based semantic screening (optional, requires LLMScreener)
9+
3. RELEASE: risk-proportionate gating decision
910
10-
Future stages (adaptive scan, correlate, surveil, present, remember)
11-
will be added as the pipeline matures.
11+
Stages are sequential. Each produces a MembraneStageRecord. The release
12+
gate aggregates all prior results into a final pass/quarantine/reject decision.
1213
"""
1314

1415
from __future__ import annotations
1516

17+
from typing import TYPE_CHECKING
18+
1619
from qp_vault.enums import MembraneResult, MembraneStage, ResourceStatus
1720
from qp_vault.membrane.innate_scan import InnateScanConfig, run_innate_scan
1821
from qp_vault.membrane.release_gate import evaluate_release
1922
from qp_vault.models import MembranePipelineStatus, MembraneStageRecord
2023

24+
if TYPE_CHECKING:
25+
from qp_vault.membrane.adaptive_scan import AdaptiveScanConfig
26+
2127

2228
class MembranePipeline:
2329
"""Membrane pipeline.
2430
2531
Screens content through multiple stages before allowing indexing.
26-
Content that fails screening is quarantined.
32+
Content that fails screening is rejected. Flagged content is quarantined.
2733
2834
Args:
2935
innate_config: Configuration for the innate scan stage.
36+
adaptive_config: Configuration for the adaptive (LLM) scan stage.
37+
If None or screener is None, adaptive scan is skipped.
3038
enabled: Whether Membrane screening is active. Default True.
3139
"""
3240

3341
def __init__(
3442
self,
3543
*,
3644
innate_config: InnateScanConfig | None = None,
45+
adaptive_config: AdaptiveScanConfig | None = None,
3746
enabled: bool = True,
3847
) -> None:
3948
self._innate_config = innate_config
49+
self._adaptive_config = adaptive_config
4050
self._enabled = enabled
4151

4252
async def screen(self, content: str) -> MembranePipelineStatus:
@@ -63,23 +73,33 @@ async def screen(self, content: str) -> MembranePipelineStatus:
6373

6474
stages: list[MembraneStageRecord] = []
6575

66-
# Stage 1: Innate scan
76+
# Stage 1: Innate scan (regex patterns)
6777
innate_result = await run_innate_scan(content, self._innate_config)
6878
stages.append(innate_result)
6979

70-
# Stage 2: Release gate
80+
# Stage 2: Adaptive scan (LLM-based, optional)
81+
from qp_vault.membrane.adaptive_scan import run_adaptive_scan
82+
adaptive_result = await run_adaptive_scan(content, self._adaptive_config)
83+
stages.append(adaptive_result)
84+
85+
# Stage 3: Release gate (aggregates all prior results)
7186
release_result = await evaluate_release(stages)
7287
stages.append(release_result)
7388

7489
# Determine overall result and recommended status
7590
overall = release_result.result
76-
if overall == MembraneResult.FAIL or overall == MembraneResult.FLAG:
91+
if overall in (MembraneResult.FAIL, MembraneResult.FLAG):
7792
status = ResourceStatus.QUARANTINED
7893
else:
7994
status = ResourceStatus.INDEXED
8095

96+
# Compute aggregate risk score from non-skipped stages
97+
risk_scores = [s.risk_score for s in stages if s.result != MembraneResult.SKIP]
98+
aggregate_risk = max(risk_scores) if risk_scores else 0.0
99+
81100
return MembranePipelineStatus(
82101
stages=stages,
83102
overall_result=overall,
84103
recommended_status=status,
104+
aggregate_risk_score=aggregate_risk,
85105
)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Copyright 2026 Quantum Pipes Technologies, LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""LLM screener implementations for Membrane ADAPTIVE_SCAN."""

0 commit comments

Comments
 (0)