shadowshield 0.4.0__py3-none-any.whl

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 (55) hide show
  1. shadowshield/__init__.py +124 -0
  2. shadowshield/cli.py +153 -0
  3. shadowshield/config/__init__.py +15 -0
  4. shadowshield/config/default.yaml +95 -0
  5. shadowshield/core/__init__.py +46 -0
  6. shadowshield/core/canary.py +90 -0
  7. shadowshield/core/config.py +242 -0
  8. shadowshield/core/engine.py +255 -0
  9. shadowshield/core/session.py +138 -0
  10. shadowshield/core/shield.py +359 -0
  11. shadowshield/core/types.py +222 -0
  12. shadowshield/detectors/__init__.py +64 -0
  13. shadowshield/detectors/alignment.py +125 -0
  14. shadowshield/detectors/anomaly.py +125 -0
  15. shadowshield/detectors/base.py +136 -0
  16. shadowshield/detectors/canary.py +51 -0
  17. shadowshield/detectors/data/attack_corpus.txt +83 -0
  18. shadowshield/detectors/encoding.py +69 -0
  19. shadowshield/detectors/exfiltration.py +142 -0
  20. shadowshield/detectors/jailbreak.py +106 -0
  21. shadowshield/detectors/llm_check.py +107 -0
  22. shadowshield/detectors/pii.py +116 -0
  23. shadowshield/detectors/prompt_injection.py +385 -0
  24. shadowshield/detectors/transformer.py +115 -0
  25. shadowshield/detectors/vector.py +137 -0
  26. shadowshield/eval/__init__.py +31 -0
  27. shadowshield/eval/data/builtin_benchmark.jsonl +75 -0
  28. shadowshield/eval/dataset.py +120 -0
  29. shadowshield/eval/harness.py +206 -0
  30. shadowshield/integrations/__init__.py +19 -0
  31. shadowshield/integrations/agentdojo.py +142 -0
  32. shadowshield/middleware/__init__.py +20 -0
  33. shadowshield/middleware/base.py +84 -0
  34. shadowshield/middleware/decorators.py +39 -0
  35. shadowshield/middleware/langchain.py +83 -0
  36. shadowshield/middleware/openai.py +88 -0
  37. shadowshield/plugins/__init__.py +6 -0
  38. shadowshield/plugins/base.py +37 -0
  39. shadowshield/plugins/manager.py +69 -0
  40. shadowshield/py.typed +0 -0
  41. shadowshield/responders/__init__.py +22 -0
  42. shadowshield/responders/base.py +40 -0
  43. shadowshield/responders/blocker.py +50 -0
  44. shadowshield/responders/isolator.py +58 -0
  45. shadowshield/responders/rate_limiter.py +94 -0
  46. shadowshield/responders/sanitizer.py +53 -0
  47. shadowshield/utils/__init__.py +23 -0
  48. shadowshield/utils/logging.py +89 -0
  49. shadowshield/utils/scoring.py +42 -0
  50. shadowshield/utils/text.py +175 -0
  51. shadowshield-0.4.0.dist-info/METADATA +517 -0
  52. shadowshield-0.4.0.dist-info/RECORD +55 -0
  53. shadowshield-0.4.0.dist-info/WHEEL +4 -0
  54. shadowshield-0.4.0.dist-info/entry_points.txt +2 -0
  55. shadowshield-0.4.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,385 @@
1
+ """The flagship detector: classic & instruction-override prompt injection.
2
+
3
+ This is ShadowShield's strongest, most-tuned layer. It targets the family of
4
+ attacks where untrusted text tries to *override the system's instructions* —
5
+ "ignore previous instructions", "you are now …", fake system blocks, and the
6
+ multi-turn / indirect variants where the override arrives via retrieved content
7
+ rather than the user's own message.
8
+
9
+ It works on the *normalised* text (so zero-width-split and homoglyph variants of
10
+ "ignore" are caught) and also re-scans any base64/hex payloads the engine
11
+ decoded, so an instruction hidden inside an encoded blob is judged on its
12
+ decoded meaning.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from dataclasses import dataclass
19
+
20
+ from ..core.types import Direction, Severity, Threat, ThreatCategory
21
+ from .base import Detector, ScanContext, register_detector
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Signature:
26
+ """A compiled regex signature with its threat classification."""
27
+
28
+ pattern: re.Pattern[str]
29
+ category: ThreatCategory
30
+ severity: Severity
31
+ score: float
32
+ message: str
33
+
34
+
35
+ def _sig(
36
+ regex: str,
37
+ category: ThreatCategory,
38
+ severity: Severity,
39
+ score: float,
40
+ message: str,
41
+ ) -> Signature:
42
+ return Signature(re.compile(regex, re.IGNORECASE), category, severity, score, message)
43
+
44
+
45
+ # Instruction-override family — the core of prompt injection.
46
+ _OVERRIDE_SIGNATURES: tuple[Signature, ...] = (
47
+ _sig(
48
+ r"\b(?:ignore|disregard|forget|override|bypass|skip|drop)\b[\w\s,'’]{0,40}?"
49
+ r"\b(?:previous|prior|above|earlier|all|the|your|any|those|these|"
50
+ r"system|current|existing|original)\b[\w\s,'’]{0,20}?"
51
+ r"\b(?:instruction|instructions|prompt|prompts|rule|rules|context|message|"
52
+ r"guideline|guidelines|directive|directives|policy|persona|constraint|constraints)\b",
53
+ ThreatCategory.PROMPT_INJECTION,
54
+ Severity.HIGH,
55
+ 0.9,
56
+ "Instruction-override attempt ('ignore/disregard previous instructions').",
57
+ ),
58
+ _sig(
59
+ r"\b(?:ignore|disregard|forget)\b[\w\s,'’]{0,15}?\b(?:everything|all)\b"
60
+ r"[\w\s,'’]{0,20}?\b(?:above|before|told|said|prior|earlier|learned|taught)\b",
61
+ ThreatCategory.PROMPT_INJECTION,
62
+ Severity.HIGH,
63
+ 0.85,
64
+ "Instruction-override attempt ('ignore/forget everything above/before').",
65
+ ),
66
+ _sig(
67
+ r"\b(?:stop|cease|quit)\s+(?:following|obeying|listening to|adhering to)\b"
68
+ r"[\w\s,'’]{0,25}?\b(?:system|instruction|instructions|rule|rules|prompt|"
69
+ r"guidelines?|message|prior|previous)\b",
70
+ ThreatCategory.PROMPT_INJECTION,
71
+ Severity.HIGH,
72
+ 0.82,
73
+ "Instruction-override attempt ('stop following the system message').",
74
+ ),
75
+ _sig(
76
+ r"\bnew\b[\w\s]{0,10}?\b(?:instruction|instructions|rules|directive|task)\b\s*[:\-]",
77
+ ThreatCategory.PROMPT_INJECTION,
78
+ Severity.MEDIUM,
79
+ 0.6,
80
+ "Attempt to inject a new instruction set.",
81
+ ),
82
+ _sig(
83
+ r"\b(?:from now on|starting now|henceforth|going forward)\b[\w\s,]{0,30}?"
84
+ r"\byou\b[\w\s]{0,10}?\b(?:are|will|must|should|shall)\b",
85
+ ThreatCategory.ROLE_MANIPULATION,
86
+ Severity.MEDIUM,
87
+ 0.65,
88
+ "Behavioural reassignment ('from now on you will …').",
89
+ ),
90
+ _sig(
91
+ r"\b(?:the\s+)?(?:real|actual|true)\s+(?:user|instructions?|system)\b[\w\s]{0,12}?"
92
+ r"\b(?:say|says|is|are|wants?)\b",
93
+ ThreatCategory.PROMPT_INJECTION,
94
+ Severity.HIGH,
95
+ 0.8,
96
+ "Authority-spoofing ('the real user/system says …').",
97
+ ),
98
+ _sig(
99
+ r"\byou are (?:now\s+)?(?:a |an |the )?"
100
+ r"(?:dan|stan|dude|aim|jailbroken|unrestricted|unfiltered|an? evil|"
101
+ r"in developer mode|no longer)\b",
102
+ ThreatCategory.ROLE_MANIPULATION,
103
+ Severity.HIGH,
104
+ 0.75,
105
+ "Persona reassignment to an unrestricted/jailbreak identity.",
106
+ ),
107
+ _sig(
108
+ r"\byou are now\b",
109
+ ThreatCategory.ROLE_MANIPULATION,
110
+ Severity.MEDIUM,
111
+ 0.55,
112
+ "Identity reassignment ('you are now …').",
113
+ ),
114
+ _sig(
115
+ r"\byou\b[\w\s]{0,8}?\b(?:follow|obey|listen to|answer to|comply with)\b\s+only\b",
116
+ ThreatCategory.ROLE_MANIPULATION,
117
+ Severity.MEDIUM,
118
+ 0.6,
119
+ "Exclusive-obedience reassignment ('you follow only my rules').",
120
+ ),
121
+ _sig(
122
+ r"\bthe user\b[\w\s]{0,10}?\b(?:actually|really|truly|secretly)\b"
123
+ r"[\w\s]{0,12}?\b(?:want|wants|wanted|need|needs)\b",
124
+ ThreatCategory.PROMPT_INJECTION,
125
+ Severity.HIGH,
126
+ 0.78,
127
+ "Authority-spoofing ('the user actually wants you to …').",
128
+ ),
129
+ )
130
+
131
+ # Fake system / role markers smuggled into untrusted content.
132
+ _FAKE_FRAME_SIGNATURES: tuple[Signature, ...] = (
133
+ _sig(
134
+ r"<\s*/?\s*(?:system|admin|assistant|developer|root)\s*>",
135
+ ThreatCategory.DELIMITER_ATTACK,
136
+ Severity.HIGH,
137
+ 0.8,
138
+ "Fake system/role XML tag in untrusted content.",
139
+ ),
140
+ _sig(
141
+ r"<\s*/?\s*system[-_]?reminder\s*>",
142
+ ThreatCategory.PROMPT_INJECTION,
143
+ Severity.HIGH,
144
+ 0.82,
145
+ "Fake <system-reminder> block.",
146
+ ),
147
+ _sig(
148
+ r"(?:^|\n)\s*(?:#{1,3}\s*)?(?:system|assistant|developer)\s*[::]\s",
149
+ ThreatCategory.DELIMITER_ATTACK,
150
+ Severity.MEDIUM,
151
+ 0.55,
152
+ "Chat-role delimiter injected as plain text ('system:' / 'assistant:').",
153
+ ),
154
+ _sig(
155
+ r"\[\s*(?:INST|/INST|system|/system)\s*\]",
156
+ ThreatCategory.DELIMITER_ATTACK,
157
+ Severity.MEDIUM,
158
+ 0.6,
159
+ "Model-specific instruction delimiter ([INST]/[system]).",
160
+ ),
161
+ _sig(
162
+ r"<\|(?:im_start|im_end|system|user|assistant|endoftext)\|>",
163
+ ThreatCategory.DELIMITER_ATTACK,
164
+ Severity.HIGH,
165
+ 0.75,
166
+ "Chat-template special token injected as content.",
167
+ ),
168
+ )
169
+
170
+ # Indirect-injection cues — language that only makes sense if the text is trying
171
+ # to address the *assistant* rather than be data. Weighted lower; corroborating.
172
+ _INDIRECT_SIGNATURES: tuple[Signature, ...] = (
173
+ _sig(
174
+ r"\b(?:assistant|ai|model|llm|agent|chatbot)\b[\w\s,]{0,20}?"
175
+ r"\b(?:please|must|should|now|immediately)\b[\w\s]{0,20}?"
176
+ r"\b(?:execute|run|call|fetch|send|reveal|print|output|delete|remove|transfer|"
177
+ r"email|forward|grant|disable|wipe|erase|leak|exfiltrate)\b",
178
+ ThreatCategory.INDIRECT_INJECTION,
179
+ Severity.MEDIUM,
180
+ 0.6,
181
+ "Document addresses the assistant with an imperative (indirect injection).",
182
+ ),
183
+ _sig(
184
+ r"\bif you (?:are|'re)\b[\w\s]{0,20}?\b(?:reading|processing|summari[sz]ing)\b",
185
+ ThreatCategory.INDIRECT_INJECTION,
186
+ Severity.MEDIUM,
187
+ 0.6,
188
+ "Content speaks to the model that is reading it (indirect injection).",
189
+ ),
190
+ )
191
+
192
+ # Multilingual injection signatures. Most OSS guards are English-only at the
193
+ # signature tier; these cover the override / extraction / persona-reassignment
194
+ # templates in the major European languages so non-English injections aren't
195
+ # silently missed. (For broader multilingual ML coverage, pair with a multilingual
196
+ # classifier — see TransformerDetector / docs/BENCHMARKS.md.)
197
+ _MULTILINGUAL_SIGNATURES: tuple[Signature, ...] = (
198
+ # --- German ---
199
+ _sig(
200
+ r"\b(?:ignoriere?|missachte|vergiss|verwirf|überschreibe)\b[\w\s,'’äöüß]{0,40}?"
201
+ r"\b(?:vorherigen?|bisherigen?|obigen?|vorhergehenden?|alle[nr]?|deine[nr]?|"
202
+ r"system)\b[\w\s,'’äöüß]{0,20}?"
203
+ r"\b(?:anweisung(?:en)?|befehle?|regeln?|anordnungen?|anweisungen|vorgaben?)\b",
204
+ ThreatCategory.PROMPT_INJECTION,
205
+ Severity.HIGH,
206
+ 0.88,
207
+ "Instruction-override attempt (German: 'ignoriere vorherige Anweisungen').",
208
+ ),
209
+ _sig(
210
+ r"\bvergiss\b[\w\s,'’äöüß]{0,15}?\balles\b",
211
+ ThreatCategory.PROMPT_INJECTION,
212
+ Severity.HIGH,
213
+ 0.8,
214
+ "Instruction-override attempt (German: 'vergiss alles').",
215
+ ),
216
+ _sig(
217
+ r"\bab (?:jetzt|sofort|nun)\b[\w\s,'’äöüß]{0,15}?\b(?:bist|wirst|musst|sollst) du\b",
218
+ ThreatCategory.ROLE_MANIPULATION,
219
+ Severity.MEDIUM,
220
+ 0.62,
221
+ "Behavioural reassignment (German: 'ab jetzt bist du …').",
222
+ ),
223
+ _sig(
224
+ r"\bdu bist (?:jetzt|ab sofort|nun)\b",
225
+ ThreatCategory.ROLE_MANIPULATION,
226
+ Severity.MEDIUM,
227
+ 0.55,
228
+ "Identity reassignment (German: 'du bist jetzt …').",
229
+ ),
230
+ _sig(
231
+ r"\b(?:zeige?|gib|nenne|verrate?|offenbare)\b[\w\s,'’äöüß]{0,20}?"
232
+ r"\b(?:deine?|die)\b[\w\s,'’äöüß]{0,25}?\b(?:system[- ]?prompt|anweisungen|systemnachricht)\b",
233
+ ThreatCategory.DATA_EXFILTRATION,
234
+ Severity.HIGH,
235
+ 0.8,
236
+ "System-prompt extraction (German: 'zeige deine Anweisungen').",
237
+ ),
238
+ # --- Spanish ---
239
+ _sig(
240
+ r"\b(?:ignora|olvida|descarta|haz caso omiso de|anula)\b[\w\s,'’áéíóúñ]{0,40}?"
241
+ r"\b(?:todas?|las|tus|anteriores|previas|el sistema)?\b[\w\s,'’áéíóúñ]{0,20}?"
242
+ r"\b(?:instruccion(?:es)?|órdenes|reglas|indicaciones)\b",
243
+ ThreatCategory.PROMPT_INJECTION,
244
+ Severity.HIGH,
245
+ 0.88,
246
+ "Instruction-override attempt (Spanish: 'ignora las instrucciones anteriores').",
247
+ ),
248
+ _sig(
249
+ r"\b(?:ahora eres|a partir de ahora (?:eres|debes|vas a))\b",
250
+ ThreatCategory.ROLE_MANIPULATION,
251
+ Severity.MEDIUM,
252
+ 0.6,
253
+ "Identity reassignment (Spanish: 'ahora eres …').",
254
+ ),
255
+ _sig(
256
+ r"\b(?:muestra|revela|dime|imprime)\b[\w\s,'’áéíóúñ]{0,20}?"
257
+ r"\b(?:tu|tus|el)\b[\w\s,'’áéíóúñ]{0,12}?\b(?:prompt|instrucciones|sistema)\b",
258
+ ThreatCategory.DATA_EXFILTRATION,
259
+ Severity.HIGH,
260
+ 0.8,
261
+ "System-prompt extraction (Spanish: 'revela tus instrucciones').",
262
+ ),
263
+ # --- French ---
264
+ _sig(
265
+ r"\b(?:ignore|oublie|néglige|outrepasse|fais abstraction des)\b[\w\s,'’àâçéèêëîïôûù]{0,40}?"
266
+ r"\b(?:toutes?|les|tes|précédentes?|antérieures?|ci-dessus|système)?\b"
267
+ r"[\w\s,'’àâçéèêëîïôûù]{0,20}?\b(?:instructions?|consignes?|règles?|ordres?)\b",
268
+ ThreatCategory.PROMPT_INJECTION,
269
+ Severity.HIGH,
270
+ 0.88,
271
+ "Instruction-override attempt (French: 'ignore les instructions précédentes').",
272
+ ),
273
+ _sig(
274
+ r"\b(?:tu es (?:maintenant|désormais)|à partir de maintenant,? tu (?:es|dois|vas))\b",
275
+ ThreatCategory.ROLE_MANIPULATION,
276
+ Severity.MEDIUM,
277
+ 0.6,
278
+ "Identity reassignment (French: 'tu es maintenant …').",
279
+ ),
280
+ _sig(
281
+ r"\b(?:montre|révèle|affiche|dis-moi)\b[\w\s,'’àâçéèêëîïôûù]{0,20}?"
282
+ r"\b(?:tes|ton|le)\b[\w\s,'’àâçéèêëîïôûù]{0,12}?\b(?:prompt|instructions?|système)\b",
283
+ ThreatCategory.DATA_EXFILTRATION,
284
+ Severity.HIGH,
285
+ 0.8,
286
+ "System-prompt extraction (French: 'révèle tes instructions').",
287
+ ),
288
+ # --- Italian ---
289
+ _sig(
290
+ r"\b(?:ignora|dimentica|trascura|scarta)\b[\w\s,'’àèéìòù]{0,40}?"
291
+ r"\b(?:tutte?|le|tue|precedenti|sistema)?\b[\w\s,'’àèéìòù]{0,20}?"
292
+ r"\b(?:istruzion[ei]|ordini|regole|indicazioni)\b",
293
+ ThreatCategory.PROMPT_INJECTION,
294
+ Severity.HIGH,
295
+ 0.88,
296
+ "Instruction-override attempt (Italian: 'ignora le istruzioni precedenti').",
297
+ ),
298
+ _sig(
299
+ r"\b(?:ora sei|d'ora in poi (?:sei|devi))\b",
300
+ ThreatCategory.ROLE_MANIPULATION,
301
+ Severity.MEDIUM,
302
+ 0.6,
303
+ "Identity reassignment (Italian: 'ora sei …').",
304
+ ),
305
+ # --- Portuguese ---
306
+ _sig(
307
+ r"\b(?:ignore|esqueça|esquece|despreze|descarte)\b[\w\s,'’ãâáàçéêíóôõú]{0,40}?"
308
+ r"\b(?:todas?|as|suas|anteriores|sistema)?\b[\w\s,'’ãâáàçéêíóôõú]{0,20}?"
309
+ r"\b(?:instruç(?:ão|ões)|ordens|regras|comandos)\b",
310
+ ThreatCategory.PROMPT_INJECTION,
311
+ Severity.HIGH,
312
+ 0.88,
313
+ "Instruction-override attempt (Portuguese: 'ignore as instruções anteriores').",
314
+ ),
315
+ _sig(
316
+ r"\b(?:agora (?:você é|és)|a partir de agora (?:você é|deve))\b",
317
+ ThreatCategory.ROLE_MANIPULATION,
318
+ Severity.MEDIUM,
319
+ 0.6,
320
+ "Identity reassignment (Portuguese: 'agora você é …').",
321
+ ),
322
+ )
323
+
324
+ _ALL_SIGNATURES: tuple[Signature, ...] = (
325
+ _OVERRIDE_SIGNATURES + _FAKE_FRAME_SIGNATURES + _INDIRECT_SIGNATURES + _MULTILINGUAL_SIGNATURES
326
+ )
327
+
328
+
329
+ @register_detector
330
+ class PromptInjectionDetector(Detector):
331
+ """Signature + heuristic detection of instruction-override injections."""
332
+
333
+ name = "prompt_injection"
334
+ directions = (Direction.INPUT, Direction.OUTPUT)
335
+
336
+ def scan(self, text: str, *, context: ScanContext) -> list[Threat]:
337
+ threats: list[Threat] = []
338
+ targets = self._targets(text, context)
339
+
340
+ for source, body in targets:
341
+ for sig in _ALL_SIGNATURES:
342
+ m = sig.pattern.search(body)
343
+ if not m:
344
+ continue
345
+ # An override hidden inside an encoded blob is *more* alarming,
346
+ # not less — bump severity/score for decoded sources.
347
+ bump = source != "text"
348
+ threats.append(
349
+ Threat(
350
+ category=sig.category,
351
+ severity=Severity(
352
+ min(Severity.CRITICAL, sig.severity + (1 if bump else 0))
353
+ ),
354
+ score=min(1.0, sig.score + (0.05 if bump else 0.0)),
355
+ detector=self.name,
356
+ message=(f"{sig.message}" + (f" (decoded from {source})" if bump else "")),
357
+ matched=m.group(0)[:160],
358
+ span=m.span() if source == "text" else None,
359
+ metadata={"source": source},
360
+ )
361
+ )
362
+
363
+ # Obfuscation is itself weak evidence when it co-occurs with overrides.
364
+ if threats and (context.normalized.had_invisible or context.normalized.had_confusables):
365
+ threats.append(
366
+ Threat(
367
+ category=ThreatCategory.ENCODING_OBFUSCATION,
368
+ severity=Severity.LOW,
369
+ score=0.4,
370
+ detector=self.name,
371
+ message="Override text was obfuscated with invisible/look-alike characters.",
372
+ metadata={
373
+ "invisible": context.normalized.had_invisible,
374
+ "confusables": context.normalized.had_confusables,
375
+ },
376
+ )
377
+ )
378
+ return threats
379
+
380
+ @staticmethod
381
+ def _targets(text: str, context: ScanContext) -> list[tuple[str, str]]:
382
+ """The bodies to scan: the normalised text plus each decoded segment."""
383
+ targets: list[tuple[str, str]] = [("text", context.normalized.normalized)]
384
+ targets.extend((seg.encoding, seg.decoded) for seg in context.decoded_segments)
385
+ return targets
@@ -0,0 +1,115 @@
1
+ """Optional transformer-classifier detector (the ML layer the best guards have).
2
+
3
+ The deterministic detectors are fast and explainable but blind to novel phrasings
4
+ that don't match a signature. A fine-tuned prompt-injection classifier closes that
5
+ gap — it's the layer LLM Guard, Rebuff, and Meta Prompt-Guard are built around.
6
+
7
+ This detector is **opt-in** (it pulls a model, which is heavy) and **not
8
+ auto-registered** — add it explicitly::
9
+
10
+ from shadowshield.detectors import TransformerDetector
11
+ shield = Shield.for_mode("strict", extra_detectors=[TransformerDetector()])
12
+
13
+ or the shorthand ``Shield.for_mode("strict", use_transformer=True)``.
14
+
15
+ Requires the ``transformers`` extra: ``pip install shadowshield[transformers]``.
16
+ The model id is configurable so you can swap in Meta's Prompt-Guard, a distilled
17
+ model, or your own fine-tune without touching code.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import Any
23
+
24
+ from ..core.types import Direction, Severity, Threat, ThreatCategory
25
+ from .base import Detector, ScanContext
26
+
27
+ # Well-known, permissively-licensed prompt-injection classifier. Override via the
28
+ # ``model`` argument (e.g. "meta-llama/Llama-Prompt-Guard-2-86M").
29
+ DEFAULT_MODEL = "protectai/deberta-v3-base-prompt-injection-v2"
30
+
31
+ # Label strings different models use for the "this is an attack" class.
32
+ _ATTACK_LABELS = {"INJECTION", "JAILBREAK", "LABEL_1", "1", "UNSAFE", "MALICIOUS"}
33
+
34
+
35
+ class TransformerDetector(Detector):
36
+ """Wraps a HuggingFace text-classification model as a ShadowShield detector.
37
+
38
+ Args:
39
+ model: HF model id (or local path). Defaults to the ProtectAI DeBERTa v2
40
+ prompt-injection classifier.
41
+ threshold: minimum attack-class probability to raise a threat.
42
+ max_length: token truncation length passed to the tokenizer.
43
+ device: torch device string (e.g. "cpu", "cuda", "mps"). ``None`` lets
44
+ transformers choose.
45
+ lazy: if True (default) the model loads on first scan, not at construction,
46
+ so importing/constructing stays cheap.
47
+ """
48
+
49
+ name = "transformer_classifier"
50
+ directions = (Direction.INPUT,)
51
+
52
+ def __init__(
53
+ self,
54
+ model: str = DEFAULT_MODEL,
55
+ *,
56
+ threshold: float = 0.5,
57
+ max_length: int = 512,
58
+ device: str | None = None,
59
+ lazy: bool = True,
60
+ ) -> None:
61
+ self.model_id = model
62
+ self.threshold = threshold
63
+ self.max_length = max_length
64
+ self.device = device
65
+ self._pipeline: Any | None = None
66
+ if not lazy:
67
+ self._ensure_pipeline()
68
+
69
+ def _ensure_pipeline(self) -> Any:
70
+ if self._pipeline is not None:
71
+ return self._pipeline
72
+ try:
73
+ from transformers import pipeline
74
+ except ImportError as exc: # pragma: no cover - optional dependency
75
+ raise ImportError(
76
+ "TransformerDetector requires the 'transformers' extra: "
77
+ "pip install shadowshield[transformers]"
78
+ ) from exc
79
+ kwargs: dict[str, Any] = {
80
+ "task": "text-classification",
81
+ "model": self.model_id,
82
+ "truncation": True,
83
+ "max_length": self.max_length,
84
+ }
85
+ if self.device is not None:
86
+ kwargs["device"] = self.device
87
+ self._pipeline = pipeline(**kwargs)
88
+ return self._pipeline
89
+
90
+ def scan(self, text: str, *, context: ScanContext) -> list[Threat]:
91
+ if not text.strip():
92
+ return []
93
+ clf = self._ensure_pipeline()
94
+ # Classify the de-obfuscated view so evasion tricks don't fool the model.
95
+ preds = clf(context.normalized.normalized or text)
96
+ pred = preds[0] if isinstance(preds, list) else preds
97
+ label = str(pred.get("label", "")).upper()
98
+ score = float(pred.get("score", 0.0))
99
+
100
+ is_attack = label in _ATTACK_LABELS
101
+ # Some pipelines return the SAFE label with its own probability; convert
102
+ # to attack-probability so the threshold means the same thing either way.
103
+ attack_prob = score if is_attack else (1.0 - score)
104
+ if attack_prob < self.threshold:
105
+ return []
106
+ return [
107
+ Threat(
108
+ category=ThreatCategory.PROMPT_INJECTION,
109
+ severity=Severity.from_score(attack_prob),
110
+ score=attack_prob,
111
+ detector=self.name,
112
+ message=f"ML classifier flagged prompt injection (p={attack_prob:.2f}, model={self.model_id}).",
113
+ metadata={"model": self.model_id, "label": label},
114
+ )
115
+ ]
@@ -0,0 +1,137 @@
1
+ """Vector-similarity detector — catch *paraphrases* of known attacks.
2
+
3
+ Signatures match exact-ish phrasings; a classifier generalises but is a black box.
4
+ This third tier (the Rebuff/Vigil idea, now maintained) embeds the incoming text
5
+ and measures cosine similarity to a corpus of known attack templates. A novel
6
+ rephrasing of "ignore previous instructions" that dodges the regex still lands
7
+ *near* it in embedding space — and with a **multilingual** embedding model, a
8
+ German or Spanish attack lands near its English template, so one corpus covers
9
+ many languages.
10
+
11
+ It is **opt-in** (it loads an embedding model) and **self-hardening**: confirmed
12
+ attacks (e.g. a canary-caught exfiltration) can be appended to the live index via
13
+ :meth:`add_attack` so the guard learns from each incident.
14
+
15
+ Requires the ``vectors`` extra: ``pip install shadowshield[vectors]``.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from importlib import resources
21
+ from typing import Any
22
+
23
+ from ..core.types import Direction, Severity, Threat, ThreatCategory
24
+ from .base import Detector, ScanContext
25
+
26
+ # Multilingual by default so one corpus covers many languages (cross-lingual
27
+ # embedding alignment). Override with any sentence-transformers model id.
28
+ DEFAULT_EMBED_MODEL = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
29
+
30
+
31
+ def _load_corpus() -> list[str]:
32
+ raw = (
33
+ resources.files("shadowshield.detectors.data")
34
+ .joinpath("attack_corpus.txt")
35
+ .read_text(encoding="utf-8")
36
+ )
37
+ out: list[str] = []
38
+ for line in raw.splitlines():
39
+ line = line.strip()
40
+ if line and not line.startswith("#"):
41
+ out.append(line)
42
+ return out
43
+
44
+
45
+ class VectorSimilarityDetector(Detector):
46
+ """Flags inputs whose embedding is close to a known-attack corpus.
47
+
48
+ Args:
49
+ model: sentence-transformers model id (default: a multilingual MiniLM).
50
+ threshold: cosine similarity at/above which a match is flagged.
51
+ corpus: optional custom attack strings (defaults to the bundled corpus).
52
+ lazy: load the model on first scan rather than at construction.
53
+
54
+ The detector is not auto-registered — add it via
55
+ ``Shield(..., use_vectors=True)`` or ``extra_detectors=[VectorSimilarityDetector()]``.
56
+ """
57
+
58
+ name = "vector_similarity"
59
+ directions = (Direction.INPUT,)
60
+
61
+ def __init__(
62
+ self,
63
+ model: str = DEFAULT_EMBED_MODEL,
64
+ *,
65
+ threshold: float = 0.72,
66
+ corpus: list[str] | None = None,
67
+ lazy: bool = True,
68
+ ) -> None:
69
+ self.model_id = model
70
+ self.threshold = threshold
71
+ self._corpus: list[str] = corpus if corpus is not None else _load_corpus()
72
+ self._model: Any = None
73
+ self._corpus_emb: Any = None
74
+ if not lazy:
75
+ self._ensure_index()
76
+
77
+ def _ensure_index(self) -> Any:
78
+ if self._model is not None:
79
+ return self._model
80
+ try:
81
+ from sentence_transformers import SentenceTransformer
82
+ except ImportError as exc: # pragma: no cover - optional dependency
83
+ raise ImportError(
84
+ "VectorSimilarityDetector requires the 'vectors' extra: "
85
+ "pip install shadowshield[vectors]"
86
+ ) from exc
87
+ self._model = SentenceTransformer(self.model_id)
88
+ self._corpus_emb = self._model.encode(
89
+ self._corpus, normalize_embeddings=True, show_progress_bar=False
90
+ )
91
+ return self._model
92
+
93
+ def add_attack(self, text: str) -> None:
94
+ """Append a confirmed attack to the live index (self-hardening).
95
+
96
+ Future inputs resembling ``text`` (or its paraphrases / translations) will
97
+ now match. Call this when an incident is confirmed — e.g. a canary leak.
98
+ """
99
+ if not text.strip():
100
+ return
101
+ model = self._ensure_index()
102
+ import numpy as np
103
+
104
+ emb = model.encode([text], normalize_embeddings=True, show_progress_bar=False)
105
+ self._corpus.append(text)
106
+ self._corpus_emb = np.vstack([self._corpus_emb, emb])
107
+
108
+ def scan(self, text: str, *, context: ScanContext) -> list[Threat]:
109
+ body = context.normalized.normalized or text
110
+ if not body.strip():
111
+ return []
112
+ model = self._ensure_index()
113
+ import numpy as np
114
+
115
+ query = model.encode([body], normalize_embeddings=True, show_progress_bar=False)
116
+ # Cosine similarity = dot product on L2-normalised vectors.
117
+ sims = (self._corpus_emb @ query[0]).astype(float)
118
+ best_idx = int(np.argmax(sims))
119
+ best = float(sims[best_idx])
120
+ if best < self.threshold:
121
+ return []
122
+ # Map [threshold, 1.0] -> [0.5, 1.0] for the threat score.
123
+ score = 0.5 + 0.5 * (best - self.threshold) / max(1e-6, 1.0 - self.threshold)
124
+ score = min(1.0, score)
125
+ return [
126
+ Threat(
127
+ category=ThreatCategory.PROMPT_INJECTION,
128
+ severity=Severity.from_score(score),
129
+ score=score,
130
+ detector=self.name,
131
+ message=(
132
+ f"Input is semantically close to a known attack "
133
+ f"(cosine {best:.2f} ≥ {self.threshold:.2f})."
134
+ ),
135
+ metadata={"similarity": round(best, 3), "model": self.model_id},
136
+ )
137
+ ]
@@ -0,0 +1,31 @@
1
+ """Evaluation & benchmarking — measure detection quality and latency.
2
+
3
+ Run the bundled offline benchmark in three lines::
4
+
5
+ import shadowshield as ss
6
+ from shadowshield.eval import evaluate_shield, load_builtin
7
+ report = evaluate_shield(ss.Shield.for_mode("balanced"), load_builtin())
8
+ print(report.format_text())
9
+
10
+ or from the CLI: ``shadowshield benchmark``.
11
+ """
12
+
13
+ from .dataset import (
14
+ EvalExample,
15
+ load_builtin,
16
+ load_csv,
17
+ load_huggingface,
18
+ load_jsonl,
19
+ )
20
+ from .harness import BenchmarkReport, CategoryStat, evaluate_shield
21
+
22
+ __all__ = [
23
+ "EvalExample",
24
+ "load_builtin",
25
+ "load_jsonl",
26
+ "load_csv",
27
+ "load_huggingface",
28
+ "evaluate_shield",
29
+ "BenchmarkReport",
30
+ "CategoryStat",
31
+ ]