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.
- shadowshield/__init__.py +124 -0
- shadowshield/cli.py +153 -0
- shadowshield/config/__init__.py +15 -0
- shadowshield/config/default.yaml +95 -0
- shadowshield/core/__init__.py +46 -0
- shadowshield/core/canary.py +90 -0
- shadowshield/core/config.py +242 -0
- shadowshield/core/engine.py +255 -0
- shadowshield/core/session.py +138 -0
- shadowshield/core/shield.py +359 -0
- shadowshield/core/types.py +222 -0
- shadowshield/detectors/__init__.py +64 -0
- shadowshield/detectors/alignment.py +125 -0
- shadowshield/detectors/anomaly.py +125 -0
- shadowshield/detectors/base.py +136 -0
- shadowshield/detectors/canary.py +51 -0
- shadowshield/detectors/data/attack_corpus.txt +83 -0
- shadowshield/detectors/encoding.py +69 -0
- shadowshield/detectors/exfiltration.py +142 -0
- shadowshield/detectors/jailbreak.py +106 -0
- shadowshield/detectors/llm_check.py +107 -0
- shadowshield/detectors/pii.py +116 -0
- shadowshield/detectors/prompt_injection.py +385 -0
- shadowshield/detectors/transformer.py +115 -0
- shadowshield/detectors/vector.py +137 -0
- shadowshield/eval/__init__.py +31 -0
- shadowshield/eval/data/builtin_benchmark.jsonl +75 -0
- shadowshield/eval/dataset.py +120 -0
- shadowshield/eval/harness.py +206 -0
- shadowshield/integrations/__init__.py +19 -0
- shadowshield/integrations/agentdojo.py +142 -0
- shadowshield/middleware/__init__.py +20 -0
- shadowshield/middleware/base.py +84 -0
- shadowshield/middleware/decorators.py +39 -0
- shadowshield/middleware/langchain.py +83 -0
- shadowshield/middleware/openai.py +88 -0
- shadowshield/plugins/__init__.py +6 -0
- shadowshield/plugins/base.py +37 -0
- shadowshield/plugins/manager.py +69 -0
- shadowshield/py.typed +0 -0
- shadowshield/responders/__init__.py +22 -0
- shadowshield/responders/base.py +40 -0
- shadowshield/responders/blocker.py +50 -0
- shadowshield/responders/isolator.py +58 -0
- shadowshield/responders/rate_limiter.py +94 -0
- shadowshield/responders/sanitizer.py +53 -0
- shadowshield/utils/__init__.py +23 -0
- shadowshield/utils/logging.py +89 -0
- shadowshield/utils/scoring.py +42 -0
- shadowshield/utils/text.py +175 -0
- shadowshield-0.4.0.dist-info/METADATA +517 -0
- shadowshield-0.4.0.dist-info/RECORD +55 -0
- shadowshield-0.4.0.dist-info/WHEEL +4 -0
- shadowshield-0.4.0.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|