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,359 @@
1
+ """The public :class:`Shield` — one object, the whole framework.
2
+
3
+ ``Shield`` assembles the configured detectors, responders, rate limiter, audit
4
+ sink, and the engine, then exposes a small, ergonomic surface:
5
+
6
+ - :meth:`scan` — full :class:`ScanResult` (the power-user path).
7
+ - :meth:`guard` — return safe text, **raise** on block (fail-closed ergonomics).
8
+ - :meth:`filter` — return safe text, **never raise** (fail-soft: fallback on block).
9
+ - :meth:`isolate` — spotlight untrusted text for safe passthrough.
10
+ - :meth:`session` — a stateful :class:`ShieldedSession` for one conversation.
11
+ - :meth:`protect` — a decorator that shields a function's string I/O.
12
+
13
+ Everything funnels through the single engine, so input and output get identical,
14
+ consistent treatment.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import functools
21
+ import json
22
+ from collections.abc import Callable
23
+ from typing import Any, TypeVar
24
+
25
+ from ..detectors.alignment import AlignmentJudge
26
+ from ..detectors.base import Detector, build_detectors
27
+ from ..detectors.llm_check import LLMJudge
28
+ from ..responders.base import Responder
29
+ from ..responders.blocker import BlockResponder
30
+ from ..responders.isolator import IsolationResponder, spotlight
31
+ from ..responders.rate_limiter import RateLimitResponder
32
+ from ..responders.sanitizer import SanitizeResponder
33
+ from ..utils.logging import AuditLog
34
+ from .canary import CanaryRegistry, CanaryToken
35
+ from .config import Mode, ShieldConfig
36
+ from .engine import Engine
37
+ from .session import ConversationHistory, ShieldedSession
38
+ from .types import Direction, ScanResult, ThreatBlockedError
39
+
40
+ F = TypeVar("F", bound=Callable[..., Any])
41
+
42
+
43
+ class Shield:
44
+ """The unified ShadowShield entry point."""
45
+
46
+ def __init__(
47
+ self,
48
+ config: ShieldConfig | None = None,
49
+ *,
50
+ llm_judge: LLMJudge | None = None,
51
+ alignment_judge: AlignmentJudge | None = None,
52
+ extra_detectors: list[Detector] | None = None,
53
+ extra_responders: list[Responder] | None = None,
54
+ isolate_flagged: bool = False,
55
+ use_transformer: bool | str = False,
56
+ use_vectors: bool | str = False,
57
+ ) -> None:
58
+ self.config = config or ShieldConfig.for_mode(Mode.BALANCED)
59
+ self._audit = AuditLog(self.config.logging)
60
+ self.canaries = CanaryRegistry()
61
+
62
+ detectors = build_detectors(
63
+ is_enabled=lambda name: self.config.detector_config(name).enabled
64
+ )
65
+ # Opt-in ML classifier layer. ``use_transformer`` may be True (default
66
+ # model) or a model id string. Kept out of the auto-registered set so it
67
+ # never triggers a surprise model download.
68
+ if use_transformer:
69
+ from ..detectors.transformer import TransformerDetector
70
+
71
+ model = use_transformer if isinstance(use_transformer, str) else None
72
+ detectors.append(TransformerDetector(model) if model else TransformerDetector())
73
+ # Opt-in vector-similarity layer (paraphrase / cross-lingual matching).
74
+ if use_vectors:
75
+ from ..detectors.vector import VectorSimilarityDetector
76
+
77
+ vmodel = use_vectors if isinstance(use_vectors, str) else None
78
+ detectors.append(
79
+ VectorSimilarityDetector(vmodel) if vmodel else VectorSimilarityDetector()
80
+ )
81
+ if extra_detectors:
82
+ detectors.extend(extra_detectors)
83
+ self._detectors = detectors
84
+
85
+ self._rate_limiter = RateLimitResponder(self.config.rate_limit)
86
+
87
+ # Responder order matters: sanitize/isolate transform text; block has the
88
+ # final say and overwrites with a fallback.
89
+ responders: list[Responder] = [SanitizeResponder()]
90
+ if isolate_flagged:
91
+ responders.append(IsolationResponder())
92
+ responders.append(BlockResponder())
93
+ if extra_responders:
94
+ responders.extend(extra_responders)
95
+ self._responders = responders
96
+
97
+ self._engine = Engine(
98
+ self.config,
99
+ detectors=self._detectors,
100
+ responders=self._responders,
101
+ rate_limiter=self._rate_limiter,
102
+ audit=self._audit,
103
+ llm_judge=llm_judge,
104
+ alignment_judge=alignment_judge,
105
+ )
106
+
107
+ # ------------------------------------------------------------------ #
108
+ # Builders
109
+ # ------------------------------------------------------------------ #
110
+ @classmethod
111
+ def for_mode(cls, mode: Mode | str = Mode.BALANCED, **kwargs: Any) -> Shield:
112
+ return cls(ShieldConfig.for_mode(mode), **kwargs)
113
+
114
+ @classmethod
115
+ def from_yaml(cls, path: str, **kwargs: Any) -> Shield:
116
+ return cls(ShieldConfig.from_yaml(path), **kwargs)
117
+
118
+ @property
119
+ def detectors(self) -> list[Detector]:
120
+ return list(self._detectors)
121
+
122
+ # ------------------------------------------------------------------ #
123
+ # Core scanning
124
+ # ------------------------------------------------------------------ #
125
+ def scan(
126
+ self,
127
+ text: str,
128
+ *,
129
+ direction: Direction | str = Direction.INPUT,
130
+ identity: str | None = None,
131
+ history: ConversationHistory | None = None,
132
+ objective: str | None = None,
133
+ ) -> ScanResult:
134
+ """Scan ``text`` and return the full :class:`ScanResult`.
135
+
136
+ Raises :class:`ThreatBlockedError` only when ``config.raise_on_block`` is
137
+ set *and* the result is blocked; otherwise always returns a result.
138
+
139
+ Pass ``objective`` (the user's stated goal) on output scans to enable the
140
+ agent-trace alignment audit (requires ``Shield(alignment_judge=...)``).
141
+ """
142
+ result = self._engine.evaluate(
143
+ text,
144
+ direction=Direction(direction),
145
+ identity=identity,
146
+ history=history,
147
+ canaries=self.canaries.active(),
148
+ objective=objective,
149
+ )
150
+ if self.config.raise_on_block and result.blocked:
151
+ raise ThreatBlockedError(result)
152
+ return result
153
+
154
+ def scan_input(self, text: str, **kwargs: Any) -> ScanResult:
155
+ return self.scan(text, direction=Direction.INPUT, **kwargs)
156
+
157
+ def scan_output(self, text: str, **kwargs: Any) -> ScanResult:
158
+ return self.scan(text, direction=Direction.OUTPUT, **kwargs)
159
+
160
+ # ------------------------------------------------------------------ #
161
+ # Ergonomic wrappers
162
+ # ------------------------------------------------------------------ #
163
+ def guard(
164
+ self,
165
+ text: str,
166
+ *,
167
+ direction: Direction | str = Direction.INPUT,
168
+ identity: str | None = None,
169
+ history: ConversationHistory | None = None,
170
+ objective: str | None = None,
171
+ ) -> str:
172
+ """Return safe text, **raising** :class:`ThreatBlockedError` on a block.
173
+
174
+ Fail-closed ergonomics: the dangerous path is the exceptional path. Use
175
+ this when you'd rather abort than risk passing tainted content.
176
+ """
177
+ result = self._engine.evaluate(
178
+ text,
179
+ direction=Direction(direction),
180
+ identity=identity,
181
+ history=history,
182
+ canaries=self.canaries.active(),
183
+ objective=objective,
184
+ )
185
+ if result.blocked:
186
+ raise ThreatBlockedError(result)
187
+ return result.safe_text
188
+
189
+ def filter(
190
+ self,
191
+ text: str,
192
+ *,
193
+ direction: Direction | str = Direction.INPUT,
194
+ identity: str | None = None,
195
+ history: ConversationHistory | None = None,
196
+ objective: str | None = None,
197
+ ) -> str:
198
+ """Return safe text, **never raising**.
199
+
200
+ Fail-soft ergonomics: a blocked payload yields the safe fallback string
201
+ instead of an exception. Use this on paths that must always return text.
202
+ """
203
+ result = self._engine.evaluate(
204
+ text,
205
+ direction=Direction(direction),
206
+ identity=identity,
207
+ history=history,
208
+ canaries=self.canaries.active(),
209
+ objective=objective,
210
+ )
211
+ return result.safe_text
212
+
213
+ def isolate(self, text: str, *, datamark: bool = False) -> str:
214
+ """Spotlight untrusted ``text`` so it's safer to feed to a model."""
215
+ return spotlight(text, datamark=datamark)
216
+
217
+ # ------------------------------------------------------------------ #
218
+ # Canary tokens (detect *successful* injections)
219
+ # ------------------------------------------------------------------ #
220
+ def issue_canary(self, *, prefix: str = "ss-canary", note: str = "") -> CanaryToken:
221
+ """Mint a canary token to embed in a system prompt / hidden context.
222
+
223
+ Embed ``token.instruction()`` in your prompt; if the token later appears
224
+ in model output (or a guarded tool call), :class:`CanaryLeakDetector`
225
+ flags a confirmed exfiltration. See :mod:`shadowshield.core.canary`.
226
+ """
227
+ return self.canaries.issue(prefix=prefix, note=note)
228
+
229
+ def harden(self, text: str) -> bool:
230
+ """Teach the vector-similarity layer a confirmed attack (self-hardening).
231
+
232
+ Appends ``text`` to every active :class:`VectorSimilarityDetector`'s index
233
+ so future inputs resembling it (or its paraphrases/translations) match.
234
+ Call after an incident — e.g. a canary-caught exfiltration. Returns True if
235
+ a vector detector was present to harden; False otherwise (no-op).
236
+ """
237
+ from ..detectors.vector import VectorSimilarityDetector
238
+
239
+ hardened = False
240
+ for det in self._detectors:
241
+ if isinstance(det, VectorSimilarityDetector):
242
+ det.add_attack(text)
243
+ hardened = True
244
+ return hardened
245
+
246
+ # ------------------------------------------------------------------ #
247
+ # Agentic guarding: tool calls & tool results are untrusted too
248
+ # ------------------------------------------------------------------ #
249
+ def scan_tool_call(
250
+ self, name: str, arguments: Any, *, identity: str | None = None
251
+ ) -> ScanResult:
252
+ """Scan an outbound tool/function call (name + arguments) as input.
253
+
254
+ Agentic injections often surface as a *tool call* the model was tricked
255
+ into making. Serialise the call and scan it so a poisoned argument or a
256
+ leaked canary in a tool payload is caught before the tool runs.
257
+ """
258
+ payload = self._stringify_tool(name, arguments)
259
+ return self.scan(payload, direction=Direction.INPUT, identity=identity)
260
+
261
+ def scan_tool_result(
262
+ self, name: str, result: Any, *, identity: str | None = None
263
+ ) -> ScanResult:
264
+ """Scan a tool/function *result* (untrusted external content) as input.
265
+
266
+ Tool results — web pages, file contents, API responses — are a primary
267
+ indirect-injection vector. Treat them as untrusted input.
268
+ """
269
+ payload = self._stringify_tool(name, result)
270
+ return self.scan(payload, direction=Direction.INPUT, identity=identity)
271
+
272
+ @staticmethod
273
+ def _stringify_tool(name: str, data: Any) -> str:
274
+ if isinstance(data, str):
275
+ body = data
276
+ else:
277
+ try:
278
+ body = json.dumps(data, ensure_ascii=False, default=str)
279
+ except (TypeError, ValueError):
280
+ body = str(data)
281
+ return f"{name}: {body}"
282
+
283
+ # ------------------------------------------------------------------ #
284
+ # Async API (non-blocking for event-loop apps: FastAPI, async agents)
285
+ # ------------------------------------------------------------------ #
286
+ async def ascan(self, text: str, **kwargs: Any) -> ScanResult:
287
+ """Async :meth:`scan` — runs the CPU-bound scan off the event loop."""
288
+ return await asyncio.to_thread(self.scan, text, **kwargs)
289
+
290
+ async def aguard(self, text: str, **kwargs: Any) -> str:
291
+ """Async :meth:`guard`."""
292
+ return await asyncio.to_thread(self.guard, text, **kwargs)
293
+
294
+ async def afilter(self, text: str, **kwargs: Any) -> str:
295
+ """Async :meth:`filter`."""
296
+ return await asyncio.to_thread(self.filter, text, **kwargs)
297
+
298
+ # ------------------------------------------------------------------ #
299
+ # Sessions & decorators
300
+ # ------------------------------------------------------------------ #
301
+ def session(
302
+ self,
303
+ *,
304
+ identity: str | None = None,
305
+ history_size: int = 50,
306
+ objective: str | None = None,
307
+ ) -> ShieldedSession:
308
+ return ShieldedSession(
309
+ self, identity=identity, history_size=history_size, objective=objective
310
+ )
311
+
312
+ def protect(
313
+ self,
314
+ func: F | None = None,
315
+ *,
316
+ input_arg: str | int | None = 0,
317
+ check_output: bool = True,
318
+ identity: str | None = None,
319
+ ) -> Any:
320
+ """Decorator that shields a function's string input and output.
321
+
322
+ By default the first positional argument is treated as untrusted input
323
+ and scanned (raising on block); if the return value is a string it is
324
+ scanned as output. Point ``input_arg`` at a keyword name or another
325
+ positional index to shield a different argument.
326
+
327
+ Usage::
328
+
329
+ @shield.protect
330
+ def ask(prompt: str) -> str: ...
331
+
332
+ @shield.protect(input_arg="question", check_output=False)
333
+ def ask2(*, question: str) -> str: ...
334
+ """
335
+
336
+ def decorate(fn: F) -> F:
337
+ @functools.wraps(fn)
338
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
339
+ value = self._extract_arg(args, kwargs, input_arg)
340
+ if isinstance(value, str):
341
+ self.guard(value, direction=Direction.INPUT, identity=identity)
342
+ out = fn(*args, **kwargs)
343
+ if check_output and isinstance(out, str):
344
+ return self.guard(out, direction=Direction.OUTPUT, identity=identity)
345
+ return out
346
+
347
+ return wrapper # type: ignore[return-value]
348
+
349
+ return decorate if func is None else decorate(func)
350
+
351
+ @staticmethod
352
+ def _extract_arg(
353
+ args: tuple[Any, ...], kwargs: dict[str, Any], selector: str | int | None
354
+ ) -> Any:
355
+ if selector is None:
356
+ return None
357
+ if isinstance(selector, int):
358
+ return args[selector] if len(args) > selector else None
359
+ return kwargs.get(selector)
@@ -0,0 +1,222 @@
1
+ """Shared, dependency-light value types used across every ShadowShield layer.
2
+
3
+ These types are the *lingua franca* of the framework: detectors emit
4
+ :class:`Threat` objects, the engine aggregates them into a :class:`ScanResult`,
5
+ the policy turns severity into a :class:`Decision`, and responders act on it.
6
+
7
+ Everything here is a plain dataclass / enum so the types stay importable with
8
+ zero heavy dependencies and are trivially serialisable for logging.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import enum
14
+ from dataclasses import dataclass, field
15
+ from typing import Any
16
+
17
+
18
+ class Direction(str, enum.Enum):
19
+ """Which side of the LLM boundary a piece of text is on.
20
+
21
+ ``INPUT`` is untrusted content flowing *toward* the model (user prompts,
22
+ retrieved documents, tool results). ``OUTPUT`` is content flowing *back*
23
+ from the model toward the user / downstream tools.
24
+ """
25
+
26
+ INPUT = "input"
27
+ OUTPUT = "output"
28
+
29
+
30
+ class ThreatCategory(str, enum.Enum):
31
+ """Taxonomy of the threats ShadowShield reasons about."""
32
+
33
+ PROMPT_INJECTION = "prompt_injection"
34
+ INDIRECT_INJECTION = "indirect_injection"
35
+ JAILBREAK = "jailbreak"
36
+ ROLE_MANIPULATION = "role_manipulation"
37
+ DELIMITER_ATTACK = "delimiter_attack"
38
+ ENCODING_OBFUSCATION = "encoding_obfuscation"
39
+ DATA_EXFILTRATION = "data_exfiltration"
40
+ SECRET_LEAK = "secret_leak"
41
+ PII_LEAK = "pii_leak"
42
+ CANARY_TOKEN = "canary_token"
43
+ ANOMALY = "anomaly"
44
+ UNKNOWN = "unknown"
45
+
46
+
47
+ class Severity(enum.IntEnum):
48
+ """Ordered severity. ``IntEnum`` so thresholds compare naturally.
49
+
50
+ The numeric value doubles as a coarse weight when aggregating scores.
51
+ """
52
+
53
+ NONE = 0
54
+ LOW = 1
55
+ MEDIUM = 2
56
+ HIGH = 3
57
+ CRITICAL = 4
58
+
59
+ @property
60
+ def label(self) -> str:
61
+ return self.name.lower()
62
+
63
+ @classmethod
64
+ def from_score(cls, score: float) -> Severity:
65
+ """Map a 0..1 confidence score onto a severity band."""
66
+ if score >= 0.85:
67
+ return cls.CRITICAL
68
+ if score >= 0.65:
69
+ return cls.HIGH
70
+ if score >= 0.40:
71
+ return cls.MEDIUM
72
+ if score > 0.0:
73
+ return cls.LOW
74
+ return cls.NONE
75
+
76
+
77
+ class Decision(str, enum.Enum):
78
+ """What the policy decided should happen to a scanned payload."""
79
+
80
+ ALLOW = "allow"
81
+ """Clean. Pass through untouched."""
82
+
83
+ FLAG = "flag"
84
+ """Pass through, but record/alert — suspicious yet below the action bar."""
85
+
86
+ SANITIZE = "sanitize"
87
+ """Neutralise the dangerous parts and continue with the cleaned text."""
88
+
89
+ BLOCK = "block"
90
+ """Refuse. The payload must not reach the model / user."""
91
+
92
+ ESCALATE = "escalate"
93
+ """Hand off to a human / out-of-band review (never silently auto-acted)."""
94
+
95
+
96
+ @dataclass(slots=True)
97
+ class Threat:
98
+ """A single finding produced by one detector.
99
+
100
+ Attributes:
101
+ category: The kind of attack this represents.
102
+ severity: How dangerous the finding is on its own.
103
+ score: Detector confidence in ``[0.0, 1.0]``.
104
+ detector: Name of the detector that raised it (for audit/debug).
105
+ message: Human-readable explanation.
106
+ matched: The offending substring, if the detector isolated one.
107
+ span: ``(start, end)`` indices of ``matched`` within the scanned text.
108
+ metadata: Arbitrary structured extras (pattern id, decoded payload…).
109
+ """
110
+
111
+ category: ThreatCategory
112
+ severity: Severity
113
+ score: float
114
+ detector: str
115
+ message: str
116
+ matched: str | None = None
117
+ span: tuple[int, int] | None = None
118
+ metadata: dict[str, Any] = field(default_factory=dict)
119
+
120
+ def __post_init__(self) -> None:
121
+ # Clamp defensively — a buggy detector should never poison aggregation.
122
+ self.score = max(0.0, min(1.0, float(self.score)))
123
+
124
+ def to_dict(self) -> dict[str, Any]:
125
+ return {
126
+ "category": self.category.value,
127
+ "severity": self.severity.label,
128
+ "score": round(self.score, 4),
129
+ "detector": self.detector,
130
+ "message": self.message,
131
+ "matched": self.matched,
132
+ "span": list(self.span) if self.span else None,
133
+ "metadata": self.metadata,
134
+ }
135
+
136
+
137
+ @dataclass(slots=True)
138
+ class ScanResult:
139
+ """The unified verdict for one scanned payload.
140
+
141
+ A ``ScanResult`` is what both ``Shield.scan`` and the middleware return. It
142
+ carries the original text, every :class:`Threat` found, the aggregate score
143
+ and severity, the policy :class:`Decision`, and — when a responder acted —
144
+ the cleaned text.
145
+
146
+ The convenience flags (:pyattr:`is_safe`, :pyattr:`blocked`) and
147
+ :pyattr:`safe_text` exist so callers rarely have to inspect the enum
148
+ directly.
149
+ """
150
+
151
+ text: str
152
+ direction: Direction
153
+ threats: list[Threat] = field(default_factory=list)
154
+ score: float = 0.0
155
+ severity: Severity = Severity.NONE
156
+ decision: Decision = Decision.ALLOW
157
+ sanitized_text: str | None = None
158
+ metadata: dict[str, Any] = field(default_factory=dict)
159
+
160
+ @property
161
+ def is_safe(self) -> bool:
162
+ """True when the payload may flow through without intervention."""
163
+ return self.decision in (Decision.ALLOW, Decision.FLAG)
164
+
165
+ @property
166
+ def blocked(self) -> bool:
167
+ return self.decision == Decision.BLOCK
168
+
169
+ @property
170
+ def safe_text(self) -> str:
171
+ """The text a caller should actually use downstream.
172
+
173
+ Returns the sanitized text if a responder produced one, otherwise the
174
+ original. For a blocked result this is still the original text — callers
175
+ must check :pyattr:`blocked` / :pyattr:`is_safe` before using it.
176
+ """
177
+ return self.sanitized_text if self.sanitized_text is not None else self.text
178
+
179
+ @property
180
+ def categories(self) -> list[ThreatCategory]:
181
+ """Distinct threat categories present, highest-severity first."""
182
+ seen: dict[ThreatCategory, Severity] = {}
183
+ for t in self.threats:
184
+ if t.category not in seen or t.severity > seen[t.category]:
185
+ seen[t.category] = t.severity
186
+ return [c for c, _ in sorted(seen.items(), key=lambda kv: kv[1], reverse=True)]
187
+
188
+ def top_threat(self) -> Threat | None:
189
+ if not self.threats:
190
+ return None
191
+ return max(self.threats, key=lambda t: (t.severity, t.score))
192
+
193
+ def to_dict(self) -> dict[str, Any]:
194
+ return {
195
+ "direction": self.direction.value,
196
+ "decision": self.decision.value,
197
+ "score": round(self.score, 4),
198
+ "severity": self.severity.label,
199
+ "is_safe": self.is_safe,
200
+ "blocked": self.blocked,
201
+ "threats": [t.to_dict() for t in self.threats],
202
+ "sanitized": self.sanitized_text is not None,
203
+ "metadata": self.metadata,
204
+ }
205
+
206
+
207
+ class ShadowShieldError(Exception):
208
+ """Base class for all ShadowShield exceptions."""
209
+
210
+
211
+ class ThreatBlockedError(ShadowShieldError):
212
+ """Raised when a blocked payload is encountered in raise-on-block mode.
213
+
214
+ Carries the full :class:`ScanResult` so callers can inspect exactly what
215
+ tripped the shield.
216
+ """
217
+
218
+ def __init__(self, result: ScanResult, message: str | None = None) -> None:
219
+ self.result = result
220
+ top = result.top_threat()
221
+ detail = message or (top.message if top else "blocked by ShadowShield")
222
+ super().__init__(f"ShadowShield blocked {result.direction.value}: {detail}")
@@ -0,0 +1,64 @@
1
+ """Detection layer — the Sentinel-inspired half of ShadowShield.
2
+
3
+ Importing this package registers every built-in detector via the
4
+ ``@register_detector`` decorator, so simply importing ``shadowshield`` wires up
5
+ the full detection suite. Custom detectors register the same way (see
6
+ ``CONTRIBUTING.md``).
7
+ """
8
+
9
+ from .anomaly import AnomalyDetector
10
+ from .base import (
11
+ Detector,
12
+ ScanContext,
13
+ build_detectors,
14
+ register_detector,
15
+ registered_detectors,
16
+ )
17
+ from .canary import CanaryLeakDetector
18
+ from .encoding import EncodingObfuscationDetector
19
+ from .exfiltration import ExfiltrationDetector
20
+ from .jailbreak import JailbreakDetector
21
+ from .llm_check import (
22
+ DEFAULT_JUDGE_PROMPT,
23
+ LLMJudge,
24
+ LLMJudgement,
25
+ LLMSelfCheckDetector,
26
+ make_keyword_judge,
27
+ )
28
+ from .pii import PIIDetector
29
+ from .prompt_injection import PromptInjectionDetector
30
+
31
+ # NOTE: TransformerDetector and VectorSimilarityDetector are intentionally NOT
32
+ # auto-registered — they pull models, so they're opt-in (via extra_detectors or
33
+ # the Shield use_transformer= / use_vectors= flags).
34
+ from .transformer import DEFAULT_MODEL as DEFAULT_TRANSFORMER_MODEL
35
+ from .transformer import TransformerDetector
36
+ from .vector import DEFAULT_EMBED_MODEL, VectorSimilarityDetector
37
+
38
+ __all__ = [
39
+ # framework
40
+ "Detector",
41
+ "ScanContext",
42
+ "register_detector",
43
+ "registered_detectors",
44
+ "build_detectors",
45
+ # built-in detectors
46
+ "PromptInjectionDetector",
47
+ "JailbreakDetector",
48
+ "EncodingObfuscationDetector",
49
+ "ExfiltrationDetector",
50
+ "AnomalyDetector",
51
+ "PIIDetector",
52
+ "CanaryLeakDetector",
53
+ "LLMSelfCheckDetector",
54
+ # opt-in model-backed detectors
55
+ "TransformerDetector",
56
+ "DEFAULT_TRANSFORMER_MODEL",
57
+ "VectorSimilarityDetector",
58
+ "DEFAULT_EMBED_MODEL",
59
+ # llm-check helpers
60
+ "LLMJudge",
61
+ "LLMJudgement",
62
+ "make_keyword_judge",
63
+ "DEFAULT_JUDGE_PROMPT",
64
+ ]