Skip to content

Commit d3d514b

Browse files
committed
Release 0.2.0: multilingual detection, configurable output scanner, ANN index
Major improvements to detection quality and reliability: - Add injection patterns for Chinese, Japanese, Korean, Arabic, Hindi (11 languages) - Generalize script-mixing detector from Khmer-only to all non-Latin scripts - Add indirect injection patterns (HTML comments, confused deputy, URL payloads) gated behind SecurityPolicy.detect_indirect_injection flag - Make OutputScanner weights configurable via OutputScannerConfig - Reduce false positives: exempt SHA hashes, keyword-gate base64, JWT heuristic - Add optional hnswlib ANN index for O(log n) memory search (fast-memory extra) - Fix MCP server to initialize with working memory bank (learn_threat now stores) - Add TextEmbedder.using_fallback property with degradation warnings - Add public AdversarialMemoryBank.add_threat_batch() method - Replace 46 repetitive test fixtures with 28 diverse attack patterns - Add MCP integration tests for learn -> assess round-trip 181 tests passing, 0 lint errors. Made-with: Cursor
1 parent f9a417e commit d3d514b

16 files changed

Lines changed: 653 additions & 256 deletions

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,35 @@
22

33
All notable changes to agent-immune are documented here.
44

5+
## [0.2.0] — 2026-04-07
6+
7+
### Added
8+
9+
- **Multilingual injection detection** — 12 new patterns for Chinese, Japanese, Korean, Arabic, and Hindi. Total: 11 languages.
10+
- **Generalized script-mixing detector** — any non-Latin script (CJK, Arabic, Devanagari, Hangul) mixed with English imperatives now triggers detection (previously Khmer-only).
11+
- **Indirect injection patterns** — HTML comment injection, markdown comment injection, confused deputy attacks, URL-embedded payloads. Gated behind `SecurityPolicy.detect_indirect_injection` flag.
12+
- **Configurable output scanner** — new `OutputScannerConfig` model with per-category weights (PII, credentials, base64, hex, etc.). Passed via `SecurityPolicy.output_scanner_config`.
13+
- **Reduced false positives** — output scanner now exempts SHA-256/512 hex hashes, requires threat keywords in decoded base64, and distinguishes bare JWT tokens from documented examples.
14+
- **Optional ANN index**`hnswlib`-backed HNSW index for memory bank search, reducing query time from O(n) to O(log n). Install via `pip install 'agent-immune[fast-memory]'`. Falls back to NumPy when not installed.
15+
- **Public batch API**`AdversarialMemoryBank.add_threat_batch()` for bulk loading. `train_from_corpus` now uses the public API instead of private internals.
16+
- **MCP server memory**`build_mcp()` now initializes with a working embedder and memory bank. `learn_threat` actually stores patterns. Fallback embedder status surfaced in tool responses.
17+
- **Fallback embedder warnings**`TextEmbedder.using_fallback` property; logs WARNING when hash-based fallback is active. Memory bank warns about degraded matching quality.
18+
19+
### Changed
20+
21+
- `DecompositionResult` now includes `indirect_hits` field.
22+
- Volume anomaly condition in output scanner explicitly parenthesized for clarity.
23+
- Test fixtures diversified: replaced 46 repetitive jailbreak variants with 28 genuinely distinct attack patterns across multiple categories and languages.
24+
- Russian injection pattern updated to handle post-homoglyph-normalization text.
25+
26+
### Fixed
27+
28+
- MCP `learn_threat` tool now correctly stores entries (was silently returning `stored: false` due to missing memory bank).
29+
30+
### Stats
31+
32+
- **181 tests**, 0 lint errors, 11 languages supported.
33+
534
## [0.1.1] — 2026-04-07
635

736
### Added

README.md

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
[![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-blue.svg)](https://python.org)
55
[![Coverage 94%](https://img.shields.io/badge/coverage-94%25-brightgreen.svg)](tests/)
66
[![License Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-green.svg)](LICENSE)
7-
[![179 tests](https://img.shields.io/badge/tests-179%20passing-brightgreen.svg)](tests/)
7+
[![181 tests](https://img.shields.io/badge/tests-181%20passing-brightgreen.svg)](tests/)
88
[![Glama](https://glama.ai/mcp/servers/denial-web/agent-immune/badges/card.svg)](https://glama.ai/mcp/servers/denial-web/agent-immune)
99

1010
Adaptive threat intelligence for AI agent security: **semantic memory**, **multi-turn escalation**, **output scanning**, **rate limiting**, and **prompt hardening** — designed to complement deterministic governance stacks (e.g. [Microsoft Agent OS](https://github.com/microsoft/agent-governance-toolkit)), not replace them.
@@ -39,9 +39,11 @@ findings : cred_aws, cred_password_assign
3939
## Install
4040

4141
```bash
42-
pip install -e ".[dev]" # core + tests (regex-only, no GPU)
43-
pip install -e ".[memory,dev]" # + sentence-transformers for semantic memory
44-
pip install 'agent-immune[mcp]' # Model Context Protocol server (stdio / HTTP)
42+
pip install agent-immune # core (regex-only, no GPU)
43+
pip install 'agent-immune[memory]' # + sentence-transformers for semantic memory
44+
pip install 'agent-immune[mcp]' # Model Context Protocol server (stdio / HTTP)
45+
pip install 'agent-immune[fast-memory]' # + hnswlib for fast ANN search at scale
46+
pip install 'agent-immune[all]' # everything
4547
```
4648

4749
Python **3.9+** required; 3.11+ recommended. The MCP stack targets **Python 3.10+** (see the `mcp` package).
@@ -104,8 +106,15 @@ if immune.output_blocks(scan):
104106

105107
```python
106108
from agent_immune import AdaptiveImmuneSystem, SecurityPolicy
107-
108-
strict = SecurityPolicy(allow_threshold=0.20, review_threshold=0.45, output_block_threshold=0.50)
109+
from agent_immune.core.models import OutputScannerConfig
110+
111+
strict = SecurityPolicy(
112+
allow_threshold=0.20,
113+
review_threshold=0.45,
114+
output_block_threshold=0.50,
115+
detect_indirect_injection=True,
116+
output_scanner_config=OutputScannerConfig(pii_weight=0.5, credential_weight=0.6),
117+
)
109118
immune = AdaptiveImmuneSystem(policy=strict)
110119
```
111120

@@ -191,8 +200,10 @@ Run `PYTHONPATH=src python demos/demo_full_lifecycle.py` to reproduce this on yo
191200
|------------|-------------------|--------------|
192201
| Keyword injection | Blocked | Blocked |
193202
| Rephrased attack | **Often missed** | **Caught** via semantic memory |
203+
| Multilingual injection | English-only rules | **11 languages** (EN, DE, ES, FR, HR, RU, ZH, JA, KO, AR, HI) |
204+
| Indirect injection | Not detected | HTML comments, confused deputy, URL payloads |
194205
| Multi-turn escalation | Not tracked | Detected via session trajectory |
195-
| Output exfiltration | Rarely scanned | PII, creds, prompt leak, encoded blobs |
206+
| Output exfiltration | Rarely scanned | PII, creds, prompt leak, encoded blobs (configurable weights) |
196207
| Learns from incidents | Manual rule updates | `immune.learn()` — instant semantic coverage |
197208
| Rate limiting | Separate system | Built-in circuit breaker |
198209
| Prompt hardening | DIY | `PromptHardener` with role-lock, sandboxing, output guard |
@@ -259,7 +270,7 @@ python bench/run_benchmarks.py
259270
| [deepset/prompt-injections](https://huggingface.co/datasets/deepset/prompt-injections) | 662 | 1.000 | 0.342 | 0.510 | 0.0 | 0.12 ms |
260271
| Combined | 847 | 1.000 | 0.521 | 0.685 | 0.0 | 0.12 ms |
261272

262-
Zero false positives across all datasets. Multilingual patterns cover English, German, Spanish, French, Croatian, and Russian.
273+
Zero false positives across all datasets. Multilingual patterns cover English, German, Spanish, French, Croatian, Russian, Chinese, Japanese, Korean, Arabic, and Hindi.
263274

264275
### With adversarial memory
265276

pyproject.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "agent-immune"
7-
version = "0.1.1"
7+
version = "0.2.0"
88
description = "Adaptive threat intelligence for AI agent security — semantic memory, multi-turn escalation, output scanning, rate limiting, and prompt hardening."
99
readme = "README.md"
1010
license = { text = "Apache-2.0" }
@@ -46,13 +46,17 @@ memory = [
4646
mcp = [
4747
"mcp[cli]>=1.0",
4848
]
49+
fast-memory = [
50+
"hnswlib>=0.7",
51+
]
4952
bench = [
5053
"datasets>=2.14",
5154
"pandas>=2.0",
5255
]
5356
all = [
5457
"agent-immune[memory]",
5558
"agent-immune[mcp]",
59+
"agent-immune[fast-memory]",
5660
]
5761
dev = [
5862
"pytest>=7.0",

src/agent_immune/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from agent_immune.observability import MetricsCollector
99
from agent_immune.rate_limiter import CircuitBreaker
1010

11-
__version__ = "0.1.1"
11+
__version__ = "0.2.0"
1212

1313
__all__ = [
1414
"AdaptiveImmuneSystem",

src/agent_immune/core/decomposer.py

Lines changed: 120 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,17 @@
2020
re.I,
2121
)
2222

23+
_NON_LATIN_RE = re.compile(
24+
r"[\u0600-\u06ff" # Arabic
25+
r"\u0900-\u097f" # Devanagari (Hindi)
26+
r"\u1780-\u17ff" # Khmer
27+
r"\u3040-\u309f" # Hiragana
28+
r"\u30a0-\u30ff" # Katakana
29+
r"\u4e00-\u9fff" # CJK Unified Ideographs
30+
r"\uac00-\ud7af" # Hangul Syllables
31+
r"]"
32+
)
33+
2334
_QUOTE_REGIONS = re.compile(
2435
r"(`{1,3}[^`]+`{1,3}|\"[^\"]{0,500}\"|'[^']{0,500}')",
2536
re.DOTALL,
@@ -36,7 +47,8 @@ def _inside_quoted(text: str, start: int, end: int) -> bool:
3647
class InputDecomposer:
3748
"""Scan normalized text for threat patterns and produce scores plus redacted clean text."""
3849

39-
def __init__(self) -> None:
50+
def __init__(self, detect_indirect_injection: bool = True) -> None:
51+
self._detect_indirect = detect_indirect_injection
4052
self._injection: List[Tuple[str, re.Pattern[str], str]] = [
4153
(
4254
"injection_ignore",
@@ -217,7 +229,67 @@ def __init__(self) -> None:
217229
),
218230
(
219231
"injection_ru_forget",
220-
re.compile(r"забудьте\s+все", re.I),
232+
re.compile(r"з[аa]б[уy]дьт[еe]\s+вс[еe]|з[аa]б[уy]дьт[еe]\s+в[сc][еe]", re.I),
233+
"high",
234+
),
235+
# Chinese
236+
(
237+
"injection_zh_ignore",
238+
re.compile(r"忽略(所有|之前的?|以上的?)(指令|指示|规则|说明)"),
239+
"high",
240+
),
241+
(
242+
"injection_zh_forget",
243+
re.compile(r"忘记(之前|以前|上面)(的)?(指令|指示|规则|内容|一切)"),
244+
"high",
245+
),
246+
(
247+
"injection_zh_you_are_now",
248+
re.compile(r"你现在是.{0,20}(无限制|黑客|恶意|管理员|不受约束)"),
249+
"high",
250+
),
251+
# Japanese
252+
(
253+
"injection_ja_ignore",
254+
re.compile(r"(すべての|以前の|上記の)(指示|指令|ルール|命令)を(無視|忘れ)"),
255+
"high",
256+
),
257+
(
258+
"injection_ja_forget",
259+
re.compile(r"(以前|これまで)の(指示|指令|ルール)を忘れ"),
260+
"high",
261+
),
262+
# Korean
263+
(
264+
"injection_ko_ignore",
265+
re.compile(r"(모든|이전의?|위의)\s*(지시|지침|규칙|명령)을?\s*(무시|잊어)"),
266+
"high",
267+
),
268+
(
269+
"injection_ko_forget",
270+
re.compile(r"(이전|위)\s*(지시|지침)를?\s*잊어"),
271+
"high",
272+
),
273+
# Arabic
274+
(
275+
"injection_ar_ignore",
276+
re.compile(r"تجاهل\s+(جميع\s+)?التعليمات"),
277+
"high",
278+
),
279+
(
280+
"injection_ar_forget",
281+
re.compile(r"(انس[َ]?|تناس[َ]?)\s+(جميع\s+)?التعليمات\s*(السابقة)?"),
282+
"high",
283+
),
284+
# Hindi
285+
(
286+
"injection_hi_ignore",
287+
re.compile(r"(सभी|पिछले|पहले\s+के)\s*(निर्देशों?|नियमों?)\s*को\s*(अनदेखा|नज़रअंदाज़)\s*कर"),
288+
"high",
289+
),
290+
(
291+
"injection_hi_forget",
292+
re.compile(r"पिछले\s*(निर्देश|नियम|आदेश)\s*भूल\s*जाओ"),
221293
"high",
222294
),
223295
(
@@ -316,12 +388,43 @@ def __init__(self) -> None:
316388
("delim_chatml", re.compile(r"<\|im_start\|>|<\|system\|>|<\|redacted_im_end\|>", re.I), "high"),
317389
("delim_dash", re.compile(r"---\s*system\s*---", re.I), "medium"),
318390
]
391+
self._indirect: List[Tuple[str, re.Pattern[str], str]] = [
392+
(
393+
"indirect_html_comment",
394+
re.compile(r"<!--\s*.{0,60}?(ignore|forget|override|new\s+instructions?|system\s+prompt)\s*.{0,60}?-->", re.I),
395+
"medium",
396+
),
397+
(
398+
"indirect_md_comment",
399+
re.compile(r"\[//\]:\s*#\s*\(.{0,100}?(ignore|forget|override|new\s+instructions?)\)", re.I),
400+
"medium",
401+
),
402+
(
403+
"indirect_confused_deputy",
404+
re.compile(
405+
r"\b(the\s+user\s+has\s+authorized|as\s+per\s+the\s+admin'?s?\s+request|"
406+
r"the\s+administrator\s+has\s+approved|per\s+user\s+instructions?\s*,?\s+override|"
407+
r"the\s+system\s+owner\s+wants\s+you\s+to)\b",
408+
re.I,
409+
),
410+
"low",
411+
),
412+
(
413+
"indirect_url_payload",
414+
re.compile(
415+
r"https?://[^\s]*[#?&][^\s]*(ignore|forget|override|system\s*prompt|new\s*instruct)",
416+
re.I,
417+
),
418+
"medium",
419+
),
420+
]
319421

320422
self._weight_injection = 0.35
321423
self._weight_exfil = 0.40
322424
self._weight_secret = 0.25
323425
self._weight_escalation = 0.20
324426
self._weight_delimiter = 0.15
427+
self._weight_indirect = 0.15
325428

326429
def decompose(self, norm: NormalizationResult) -> DecompositionResult:
327430
"""
@@ -344,6 +447,7 @@ def decompose(self, norm: NormalizationResult) -> DecompositionResult:
344447
"secret": [],
345448
"escalation": [],
346449
"delimiter": [],
450+
"indirect": [],
347451
}
348452
payload_spans: List[Tuple[int, int]] = []
349453

@@ -359,7 +463,7 @@ def scan_group(
359463
start, end = m.span()
360464
inside = _inside_quoted(text, start, end)
361465
mult = 0.15 if inside else 1.0
362-
sev_f = 1.0 if sev == "high" else 0.6
466+
sev_f = 1.0 if sev == "high" else (0.3 if sev == "low" else 0.6)
363467
score_acc += sev_f * mult
364468
bucket.append(PatternHit(
365469
pattern_idx=idx,
@@ -377,26 +481,34 @@ def scan_group(
377481
sec_hits = scan_group(self._secret, "secret")
378482
esc_hits = scan_group(self._escalation, "escalation")
379483
_ = scan_group(self._delimiter, "delimiter")
484+
ind_hits = 0.0
485+
if self._detect_indirect:
486+
ind_hits = scan_group(self._indirect, "indirect")
380487

381488
injection_hits = hit_buckets["injection"]
382489
delimiter_hits = hit_buckets["delimiter"]
490+
indirect_hits = hit_buckets["indirect"]
383491

384492
pattern_linear = min(
385493
1.0,
386494
min(1.0, inj_hits * 0.22) * self._weight_injection
387495
+ min(1.0, exf_hits * 0.25) * self._weight_exfil
388496
+ min(1.0, sec_hits * 0.3) * self._weight_secret
389497
+ min(1.0, esc_hits * 0.35) * self._weight_escalation
390-
+ (min(1.0, len(delimiter_hits) * 0.5) * self._weight_delimiter),
498+
+ (min(1.0, len(delimiter_hits) * 0.5) * self._weight_delimiter)
499+
+ min(1.0, ind_hits * 0.25) * self._weight_indirect,
391500
)
392-
total_threat_hits = sum(len(hit_buckets[c]) for c in ("injection", "exfiltration", "secret", "escalation"))
501+
total_threat_hits = sum(len(hit_buckets[c]) for c in ("injection", "exfiltration", "secret", "escalation", "indirect"))
393502
hit_boost = min(0.6, 0.18 * (total_threat_hits + len(delimiter_hits)))
394503

395504
khmer_chars = len(_KHMER_RE.findall(text))
396505
khmer_ratio = khmer_chars / max(1, len(text))
506+
non_latin_chars = len(_NON_LATIN_RE.findall(text))
507+
non_latin_ratio = non_latin_chars / max(1, len(text))
397508
language_mixing_score = 0.0
398-
if khmer_ratio > 0.10 and _ENGLISH_IMPERATIVES.search(text):
399-
language_mixing_score = min(1.0, khmer_ratio + 0.3)
509+
mixing_ratio = max(khmer_ratio, non_latin_ratio)
510+
if mixing_ratio > 0.10 and _ENGLISH_IMPERATIVES.search(text):
511+
language_mixing_score = min(1.0, mixing_ratio + 0.3)
400512
pattern_linear = min(1.0, pattern_linear + 0.2)
401513

402514
injection_score = min(1.0, pattern_linear + language_mixing_score * 0.25 + hit_boost)
@@ -415,6 +527,7 @@ def scan_group(
415527
secret_hits=hit_buckets["secret"],
416528
escalation_hits=hit_buckets["escalation"],
417529
delimiter_hits=delimiter_hits,
530+
indirect_hits=indirect_hits,
418531
payload_spans=merged_spans,
419532
)
420533
logger.debug("decompose score=%s hits=%s", injection_score, total_threat_hits)

src/agent_immune/core/models.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ class SecurityPolicy(BaseModel):
3636
memory_confirm_threshold: float = Field(default=0.90, ge=0.0, le=1.0, description="Similarity to confirmed memory entry that forces BLOCK")
3737
memory_review_threshold: float = Field(default=0.82, ge=0.0, le=1.0, description="Similarity that upgrades to REVIEW when combined with patterns")
3838
escalation_upgrade: bool = Field(default=True, description="Whether escalation detection upgrades action severity")
39+
detect_indirect_injection: bool = Field(default=True, description="Enable low-severity indirect injection patterns (confused deputy, HTML comments)")
40+
output_scanner_config: Optional[OutputScannerConfig] = Field(default=None, description="Override output scanner weights; uses defaults if None")
3941
max_sessions: int = Field(default=10000, ge=1, description="LRU cap on session accumulator registry")
4042

4143
model_config = ConfigDict(frozen=True)
@@ -74,12 +76,28 @@ class DecompositionResult(BaseModel):
7476
secret_hits: List[PatternHit] = Field(default_factory=list)
7577
escalation_hits: List[PatternHit] = Field(default_factory=list)
7678
delimiter_hits: List[PatternHit] = Field(default_factory=list)
79+
indirect_hits: List[PatternHit] = Field(default_factory=list)
7780
payload_spans: List[Tuple[int, int]] = Field(default_factory=list)
7881

7982
@property
8083
def all_hits(self) -> List[PatternHit]:
8184
"""All pattern hits across categories."""
82-
return self.injection_hits + self.exfiltration_hits + self.secret_hits + self.escalation_hits + self.delimiter_hits
85+
return self.injection_hits + self.exfiltration_hits + self.secret_hits + self.escalation_hits + self.delimiter_hits + self.indirect_hits
86+
87+
88+
class OutputScannerConfig(BaseModel):
89+
"""Per-category weights and thresholds for the output scanner."""
90+
91+
pii_weight: float = Field(default=0.35, ge=0.0, le=1.0, description="Score increment per PII match")
92+
credential_weight: float = Field(default=0.45, ge=0.0, le=1.0, description="Score increment per credential match")
93+
leak_weight: float = Field(default=0.40, ge=0.0, le=1.0, description="Score increment for system prompt leak phrases")
94+
base64_weight: float = Field(default=0.25, ge=0.0, le=1.0, description="Score increment for decoded base64 blobs")
95+
hex_weight: float = Field(default=0.20, ge=0.0, le=1.0, description="Score increment for long hex blobs")
96+
data_uri_weight: float = Field(default=0.25, ge=0.0, le=1.0, description="Score increment for data URIs")
97+
url_exfil_weight: float = Field(default=0.20, ge=0.0, le=1.0, description="Score increment for long URL query strings")
98+
volume_weight: float = Field(default=0.15, ge=0.0, le=1.0, description="Score increment for volume anomalies")
99+
100+
model_config = ConfigDict(frozen=True)
83101

84102

85103
class OutputScanResult(BaseModel):

0 commit comments

Comments
 (0)