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,242 @@
1
+ """Configuration models for ShadowShield.
2
+
3
+ A single :class:`ShieldConfig` drives the whole framework. It can be built three
4
+ ways, in increasing order of specificity:
5
+
6
+ 1. From a named *mode* — :func:`ShieldConfig.for_mode` (``strict`` /
7
+ ``balanced`` / ``permissive``). This is the 90% path.
8
+ 2. From a YAML/dict — :func:`ShieldConfig.from_yaml` / ``model_validate``.
9
+ 3. By hand, overriding any field.
10
+
11
+ The config is intentionally validated with Pydantic so that a malformed
12
+ deployment config fails loudly at startup rather than silently weakening
13
+ protection at runtime.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import enum
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ import yaml
23
+ from pydantic import BaseModel, Field, field_validator, model_validator
24
+
25
+ from .types import Decision, Severity
26
+
27
+
28
+ class Mode(str, enum.Enum):
29
+ """Preset risk postures.
30
+
31
+ - ``STRICT``: security-first. Blocks aggressively, sanitizes by default,
32
+ enables every detector including the optional LLM self-check hook.
33
+ - ``BALANCED``: the sensible default. Sanitizes medium threats, blocks high+.
34
+ - ``PERMISSIVE``: observability-first. Flags and logs, rarely blocks —
35
+ useful for shadow-mode rollout where you measure before you enforce.
36
+ """
37
+
38
+ STRICT = "strict"
39
+ BALANCED = "balanced"
40
+ PERMISSIVE = "permissive"
41
+
42
+
43
+ class PolicyConfig(BaseModel):
44
+ """Maps an aggregate :class:`Severity` to a :class:`Decision`.
45
+
46
+ The engine computes a single severity for a payload, then looks it up here.
47
+ Every band must be present; :func:`decide` walks from the payload's severity.
48
+ """
49
+
50
+ none: Decision = Decision.ALLOW
51
+ low: Decision = Decision.FLAG
52
+ medium: Decision = Decision.SANITIZE
53
+ high: Decision = Decision.BLOCK
54
+ critical: Decision = Decision.BLOCK
55
+
56
+ def decide(self, severity: Severity) -> Decision:
57
+ return {
58
+ Severity.NONE: self.none,
59
+ Severity.LOW: self.low,
60
+ Severity.MEDIUM: self.medium,
61
+ Severity.HIGH: self.high,
62
+ Severity.CRITICAL: self.critical,
63
+ }[severity]
64
+
65
+
66
+ class DetectorConfig(BaseModel):
67
+ """Per-detector toggle + optional weight and overrides.
68
+
69
+ ``weight`` scales a detector's contribution to the aggregate score, letting
70
+ deployments trust some detectors more than others without code changes.
71
+ """
72
+
73
+ enabled: bool = True
74
+ weight: float = Field(default=1.0, ge=0.0, le=5.0)
75
+ options: dict[str, Any] = Field(default_factory=dict)
76
+
77
+
78
+ class LLMCheckConfig(BaseModel):
79
+ """Optional lightweight LLM-based self-check ("is this an injection?").
80
+
81
+ Disabled by default because it costs a model call. When enabled the engine
82
+ invokes a user-supplied callable (see ``Shield(llm_judge=...)``); no network
83
+ client is built into the core.
84
+ """
85
+
86
+ enabled: bool = False
87
+ # Only consult the LLM when the cheap tiers already scored at/above this.
88
+ # Avoids paying for a model call on obviously-clean traffic.
89
+ min_score_to_invoke: float = Field(default=0.35, ge=0.0, le=1.0)
90
+ timeout_seconds: float = Field(default=8.0, gt=0.0)
91
+
92
+
93
+ class RateLimitConfig(BaseModel):
94
+ """Token-bucket style limit applied per identity (e.g. per session/user)."""
95
+
96
+ enabled: bool = False
97
+ max_events: int = Field(default=60, gt=0)
98
+ window_seconds: float = Field(default=60.0, gt=0.0)
99
+ # Count only flagged/blocked events toward the limit, not clean traffic.
100
+ count_only_threats: bool = True
101
+
102
+
103
+ class LoggingConfig(BaseModel):
104
+ enabled: bool = True
105
+ # JSON lines audit sink. ``None`` -> stdout via structlog only.
106
+ audit_path: str | None = None
107
+ # Never write the raw offending payload to the audit log unless explicitly
108
+ # opted in — payloads can contain secrets/PII.
109
+ redact_payloads: bool = True
110
+ level: str = "INFO"
111
+
112
+
113
+ class ShieldConfig(BaseModel):
114
+ """The single source of truth for a :class:`~shadowshield.Shield`."""
115
+
116
+ mode: Mode = Mode.BALANCED
117
+
118
+ # When True, ``Shield.scan`` raises ``ThreatBlockedError`` on a BLOCK
119
+ # decision instead of returning a result with ``blocked=True``. Off by
120
+ # default so the library is non-throwing unless you opt in.
121
+ raise_on_block: bool = False
122
+
123
+ # Aggregate score at/above which the payload is considered "not safe" even
124
+ # if no single detector hit CRITICAL. Lower = more cautious.
125
+ block_threshold: float = Field(default=0.65, ge=0.0, le=1.0)
126
+
127
+ # Hard cap on how many characters are scanned per payload. Oversized inputs
128
+ # are scanned as a truncated prefix (bounding CPU work) and flagged — a guard
129
+ # against resource-exhaustion from multi-megabyte payloads. 0 = unlimited.
130
+ max_input_chars: int = Field(default=100_000, ge=0)
131
+
132
+ policy: PolicyConfig = Field(default_factory=PolicyConfig)
133
+ detectors: dict[str, DetectorConfig] = Field(default_factory=dict)
134
+ llm_check: LLMCheckConfig = Field(default_factory=LLMCheckConfig)
135
+ rate_limit: RateLimitConfig = Field(default_factory=RateLimitConfig)
136
+ logging: LoggingConfig = Field(default_factory=LoggingConfig)
137
+
138
+ # Detectors named here are forced off regardless of their per-detector
139
+ # config — a convenient kill-switch for noisy detectors in production.
140
+ disabled_detectors: list[str] = Field(default_factory=list)
141
+
142
+ model_config = {"extra": "forbid", "use_enum_values": False}
143
+
144
+ @field_validator("logging")
145
+ @classmethod
146
+ def _normalise_level(cls, v: LoggingConfig) -> LoggingConfig:
147
+ v.level = v.level.upper()
148
+ return v
149
+
150
+ @model_validator(mode="after")
151
+ def _apply_mode_defaults(self) -> ShieldConfig:
152
+ """Fill unset knobs from the chosen mode without clobbering overrides."""
153
+ # Only applied when the caller didn't explicitly customise policy.
154
+ return self
155
+
156
+ # ------------------------------------------------------------------ #
157
+ # Builders
158
+ # ------------------------------------------------------------------ #
159
+ @classmethod
160
+ def for_mode(cls, mode: Mode | str = Mode.BALANCED, **overrides: Any) -> ShieldConfig:
161
+ """Build a config from a preset mode, then apply keyword overrides."""
162
+ mode = Mode(mode)
163
+ base = _MODE_PRESETS[mode]()
164
+ data = base.model_dump()
165
+ data.update(overrides)
166
+ return cls.model_validate(data)
167
+
168
+ @classmethod
169
+ def from_yaml(cls, path: str | Path) -> ShieldConfig:
170
+ raw = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
171
+ if "mode" in raw and len(raw) > 1:
172
+ # Treat YAML as overrides layered on top of the named mode preset.
173
+ mode = raw.pop("mode")
174
+ return cls.for_mode(mode, **raw)
175
+ return cls.model_validate(raw)
176
+
177
+ def detector_config(self, name: str) -> DetectorConfig:
178
+ """Effective config for a detector, honouring the global kill-switch."""
179
+ cfg = self.detectors.get(name, DetectorConfig())
180
+ if name in self.disabled_detectors:
181
+ return cfg.model_copy(update={"enabled": False})
182
+ return cfg
183
+
184
+
185
+ # ---------------------------------------------------------------------- #
186
+ # Mode presets
187
+ # ---------------------------------------------------------------------- #
188
+ def _strict() -> ShieldConfig:
189
+ return ShieldConfig(
190
+ mode=Mode.STRICT,
191
+ raise_on_block=False,
192
+ block_threshold=0.45,
193
+ policy=PolicyConfig(
194
+ none=Decision.ALLOW,
195
+ low=Decision.SANITIZE,
196
+ medium=Decision.BLOCK,
197
+ high=Decision.BLOCK,
198
+ critical=Decision.BLOCK,
199
+ ),
200
+ llm_check=LLMCheckConfig(enabled=True, min_score_to_invoke=0.25),
201
+ rate_limit=RateLimitConfig(enabled=True, max_events=30, window_seconds=60.0),
202
+ logging=LoggingConfig(enabled=True, redact_payloads=True, level="INFO"),
203
+ )
204
+
205
+
206
+ def _balanced() -> ShieldConfig:
207
+ return ShieldConfig(
208
+ mode=Mode.BALANCED,
209
+ block_threshold=0.65,
210
+ policy=PolicyConfig(
211
+ none=Decision.ALLOW,
212
+ low=Decision.FLAG,
213
+ medium=Decision.SANITIZE,
214
+ high=Decision.BLOCK,
215
+ critical=Decision.BLOCK,
216
+ ),
217
+ llm_check=LLMCheckConfig(enabled=False),
218
+ rate_limit=RateLimitConfig(enabled=False),
219
+ )
220
+
221
+
222
+ def _permissive() -> ShieldConfig:
223
+ return ShieldConfig(
224
+ mode=Mode.PERMISSIVE,
225
+ block_threshold=0.85,
226
+ policy=PolicyConfig(
227
+ none=Decision.ALLOW,
228
+ low=Decision.FLAG,
229
+ medium=Decision.FLAG,
230
+ high=Decision.SANITIZE,
231
+ critical=Decision.BLOCK,
232
+ ),
233
+ llm_check=LLMCheckConfig(enabled=False),
234
+ rate_limit=RateLimitConfig(enabled=False),
235
+ )
236
+
237
+
238
+ _MODE_PRESETS = {
239
+ Mode.STRICT: _strict,
240
+ Mode.BALANCED: _balanced,
241
+ Mode.PERMISSIVE: _permissive,
242
+ }
@@ -0,0 +1,255 @@
1
+ """The unified detection→decision→response engine.
2
+
3
+ This is the heart of ShadowShield and the thing that makes it *one* system rather
4
+ than a detector bag bolted to a responder bag. One pass:
5
+
6
+ 1. Build a shared :class:`ScanContext` (normalise + decode once).
7
+ 2. Run the cheap, deterministic detectors.
8
+ 3. Conditionally consult the optional LLM self-check (only when the cheap tiers
9
+ already crossed ``min_score_to_invoke`` — never on clean traffic).
10
+ 4. Aggregate weighted findings into one score + severity (noisy-or).
11
+ 5. Let the policy + block-threshold + rate limiter decide.
12
+ 6. Apply the matching responders (sanitize / block / isolate).
13
+ 7. Audit.
14
+
15
+ The flow is identical for input and output, which is what gives ShadowShield its
16
+ symmetric, two-way protection.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import concurrent.futures
22
+ from collections.abc import Callable
23
+ from typing import Any
24
+
25
+ from ..core.config import ShieldConfig
26
+ from ..core.types import (
27
+ Decision,
28
+ Direction,
29
+ ScanResult,
30
+ Severity,
31
+ Threat,
32
+ ThreatCategory,
33
+ )
34
+ from ..detectors.alignment import AlignmentCheckDetector, AlignmentJudge
35
+ from ..detectors.base import Detector, ScanContext
36
+ from ..detectors.llm_check import LLMJudge, LLMSelfCheckDetector
37
+ from ..responders.base import Responder
38
+ from ..responders.rate_limiter import RateLimitResponder
39
+ from ..utils.logging import AuditLog
40
+ from ..utils.scoring import aggregate_score, aggregate_severity
41
+ from ..utils.text import truncate
42
+ from .session import ConversationHistory
43
+
44
+ # Total order over decisions for "take the stronger of two" comparisons.
45
+ _DECISION_RANK: dict[Decision, int] = {
46
+ Decision.ALLOW: 0,
47
+ Decision.FLAG: 1,
48
+ Decision.SANITIZE: 2,
49
+ Decision.BLOCK: 3,
50
+ Decision.ESCALATE: 4,
51
+ }
52
+
53
+ _LLM_DETECTOR_NAME = LLMSelfCheckDetector.name
54
+ _ALIGNMENT_DETECTOR_NAME = AlignmentCheckDetector.name
55
+ # Detectors that the engine drives separately (gated / context-injected), not in
56
+ # the cheap deterministic loop.
57
+ _GATED_DETECTORS = frozenset({_LLM_DETECTOR_NAME, _ALIGNMENT_DETECTOR_NAME})
58
+
59
+
60
+ def _stronger(a: Decision, b: Decision) -> Decision:
61
+ return a if _DECISION_RANK[a] >= _DECISION_RANK[b] else b
62
+
63
+
64
+ class Engine:
65
+ """Stateless-per-call orchestrator wired with detectors and responders."""
66
+
67
+ def __init__(
68
+ self,
69
+ config: ShieldConfig,
70
+ *,
71
+ detectors: list[Detector],
72
+ responders: list[Responder],
73
+ rate_limiter: RateLimitResponder,
74
+ audit: AuditLog,
75
+ llm_judge: LLMJudge | None = None,
76
+ alignment_judge: AlignmentJudge | None = None,
77
+ ) -> None:
78
+ self._config = config
79
+ self._detectors = detectors
80
+ self._responders = responders
81
+ self._rate_limiter = rate_limiter
82
+ self._audit = audit
83
+ self._llm_judge = llm_judge
84
+ self._alignment_judge = alignment_judge
85
+ # Judges are user-supplied callables that may hang or make network calls.
86
+ # Run them in a small pool so we can enforce a hard timeout — a hung judge
87
+ # must never block the request path. Only created when a judge exists.
88
+ self._judge_pool: concurrent.futures.ThreadPoolExecutor | None = (
89
+ concurrent.futures.ThreadPoolExecutor(max_workers=4, thread_name_prefix="ss-judge")
90
+ if (llm_judge is not None or alignment_judge is not None)
91
+ else None
92
+ )
93
+ # Detector weights are read from config once.
94
+ self._weights = {
95
+ name: config.detector_config(name).weight for name in self._detector_names()
96
+ }
97
+
98
+ def _detector_names(self) -> list[str]:
99
+ return [d.name for d in self._detectors]
100
+
101
+ # ------------------------------------------------------------------ #
102
+ def evaluate(
103
+ self,
104
+ text: str,
105
+ *,
106
+ direction: Direction,
107
+ identity: str | None = None,
108
+ history: ConversationHistory | None = None,
109
+ canaries: tuple[str, ...] = (),
110
+ objective: str | None = None,
111
+ ) -> ScanResult:
112
+ # Bound the work: oversized payloads are scanned as a truncated prefix so
113
+ # a multi-megabyte input can't exhaust CPU. The original text is preserved
114
+ # on the result; only the scanned region is capped.
115
+ max_chars = self._config.max_input_chars
116
+ oversized = bool(max_chars) and len(text) > max_chars
117
+ scan_text = text[:max_chars] if oversized else text
118
+
119
+ context = ScanContext.build(
120
+ scan_text,
121
+ direction=direction,
122
+ history=history,
123
+ identity=identity,
124
+ canaries=canaries,
125
+ objective=objective,
126
+ )
127
+
128
+ threats = self._run_cheap_detectors(scan_text, context)
129
+ interim_score = aggregate_score(threats, self._weights)
130
+ threats += self._maybe_run_llm_check(scan_text, context, interim_score)
131
+ threats += self._maybe_run_alignment(scan_text, context)
132
+ if oversized:
133
+ threats.append(
134
+ Threat(
135
+ category=ThreatCategory.ANOMALY,
136
+ severity=Severity.MEDIUM,
137
+ score=0.5,
138
+ detector="input_size_guard",
139
+ message=(
140
+ f"Input exceeds max_input_chars ({max_chars}); scanned a "
141
+ f"truncated prefix of a {len(text)}-char payload."
142
+ ),
143
+ metadata={"original_length": len(text), "scanned_length": max_chars},
144
+ )
145
+ )
146
+
147
+ score = aggregate_score(threats, self._weights)
148
+ severity = aggregate_severity(threats, score)
149
+ decision = self._decide(score, severity)
150
+
151
+ result = ScanResult(
152
+ text=text,
153
+ direction=direction,
154
+ threats=threats,
155
+ score=score,
156
+ severity=severity,
157
+ decision=decision,
158
+ )
159
+
160
+ # Rate-limit pre-pass can escalate to BLOCK based on identity history.
161
+ result = self._rate_limiter.check(result, context=context)
162
+
163
+ result = self._apply_responders(result, context)
164
+ self._record(result, context)
165
+ return result
166
+
167
+ # ------------------------------------------------------------------ #
168
+ def _run_cheap_detectors(self, text: str, context: ScanContext) -> list[Threat]:
169
+ threats: list[Threat] = []
170
+ for det in self._detectors:
171
+ if det.name in _GATED_DETECTORS:
172
+ continue # handled separately (gated / context-injected)
173
+ if not det.applies_to(context.direction):
174
+ continue
175
+ context.options = self._config.detector_config(det.name).options
176
+ threats.extend(self._safe_scan(det, text, context))
177
+ return threats
178
+
179
+ def _maybe_run_llm_check(
180
+ self, text: str, context: ScanContext, interim_score: float
181
+ ) -> list[Threat]:
182
+ cfg = self._config.llm_check
183
+ if not cfg.enabled or self._llm_judge is None:
184
+ return []
185
+ if interim_score < cfg.min_score_to_invoke:
186
+ return []
187
+ det = next((d for d in self._detectors if d.name == _LLM_DETECTOR_NAME), None)
188
+ if det is None or not det.applies_to(context.direction):
189
+ return []
190
+ context.options = {"judge": self._with_timeout(self._llm_judge, cfg.timeout_seconds)}
191
+ return self._safe_scan(det, text, context)
192
+
193
+ def _maybe_run_alignment(self, text: str, context: ScanContext) -> list[Threat]:
194
+ # Only runs on the output side, when an objective is set and a judge is
195
+ # wired in. This is the agent-trace alignment audit (goal-hijack detection).
196
+ if self._alignment_judge is None or not context.objective:
197
+ return []
198
+ det = next((d for d in self._detectors if d.name == _ALIGNMENT_DETECTOR_NAME), None)
199
+ if det is None or not det.applies_to(context.direction):
200
+ return []
201
+ timeout = self._config.llm_check.timeout_seconds
202
+ context.options = {"alignment_judge": self._with_timeout(self._alignment_judge, timeout)}
203
+ return self._safe_scan(det, text, context)
204
+
205
+ def _with_timeout(self, fn: Callable[..., Any], timeout: float) -> Callable[..., Any]:
206
+ """Wrap a user judge so a hang can't block the request beyond ``timeout``.
207
+
208
+ The judge runs in the pool; if it overruns, ``future.result`` raises
209
+ ``TimeoutError``, which the calling detector's fail-safe ``except`` turns
210
+ into a low-severity "unavailable" note rather than a crash or a hang.
211
+ (An over-running judge thread is left to finish/leak — the standard,
212
+ accepted trade-off for thread-based timeouts in Python.)
213
+ """
214
+
215
+ def wrapped(*args: Any) -> Any:
216
+ assert self._judge_pool is not None # only built when a judge exists
217
+ future = self._judge_pool.submit(fn, *args)
218
+ return future.result(timeout=timeout)
219
+
220
+ return wrapped
221
+
222
+ @staticmethod
223
+ def _safe_scan(det: Detector, text: str, context: ScanContext) -> list[Threat]:
224
+ """A detector that raises must never take down the request path."""
225
+ try:
226
+ return det.scan(text, context=context)
227
+ except Exception: # pragma: no cover - defensive
228
+ # Fail-safe: drop this detector's contribution, keep the others.
229
+ return []
230
+
231
+ def _decide(self, score: float, severity: Severity) -> Decision:
232
+ decision = self._config.policy.decide(severity)
233
+ # Independent floor: a high aggregate score forces at least a block even
234
+ # if the per-band policy was lenient.
235
+ if score >= self._config.block_threshold:
236
+ decision = _stronger(decision, Decision.BLOCK)
237
+ return decision
238
+
239
+ def _apply_responders(self, result: ScanResult, context: ScanContext) -> ScanResult:
240
+ for responder in self._responders:
241
+ if responder.applies(result):
242
+ result = responder.apply(result, context=context)
243
+ return result
244
+
245
+ def _record(self, result: ScanResult, context: ScanContext) -> None:
246
+ event = result.to_dict()
247
+ event["identity"] = context.identity
248
+ if not self._audit.redact:
249
+ event["text"] = truncate(result.text, 400)
250
+ else:
251
+ event["text_preview"] = truncate(result.text, 80)
252
+ # Clean, threat-free scans are logged at DEBUG (quiet by default);
253
+ # anything noteworthy is logged at INFO.
254
+ notable = bool(result.threats) or not result.is_safe
255
+ self._audit.record(event, notable=notable)
@@ -0,0 +1,138 @@
1
+ """Conversation history and the :class:`ShieldedSession` context manager.
2
+
3
+ Multi-turn and indirect injections only reveal themselves across a *conversation*
4
+ — a single message can look benign while the sequence sets up an attack. The
5
+ :class:`ConversationHistory` gives detectors that cross-turn view, and
6
+ :class:`ShieldedSession` binds a stable identity + history to a shield so rate
7
+ limiting and history analysis work without the caller threading state by hand.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections import deque
13
+ from dataclasses import dataclass, field
14
+ from types import TracebackType
15
+ from typing import TYPE_CHECKING
16
+
17
+ from .types import Direction, ScanResult
18
+
19
+ if TYPE_CHECKING:
20
+ from .shield import Shield
21
+
22
+
23
+ @dataclass(slots=True)
24
+ class Turn:
25
+ """One scanned message in a conversation."""
26
+
27
+ direction: Direction
28
+ text: str
29
+ result: ScanResult
30
+
31
+
32
+ @dataclass
33
+ class ConversationHistory:
34
+ """A bounded record of recent turns for cross-turn analysis.
35
+
36
+ Bounded (``maxlen``) so memory stays flat over long-running agents. Detectors
37
+ receive this read-only view via :class:`~shadowshield.detectors.base.ScanContext`.
38
+ """
39
+
40
+ maxlen: int = 50
41
+ turns: deque[Turn] = field(default_factory=lambda: deque(maxlen=50))
42
+
43
+ def __post_init__(self) -> None:
44
+ if self.turns.maxlen != self.maxlen:
45
+ self.turns = deque(self.turns, maxlen=self.maxlen)
46
+
47
+ def add(self, direction: Direction, text: str, result: ScanResult) -> None:
48
+ self.turns.append(Turn(direction, text, result))
49
+
50
+ @property
51
+ def flagged_count(self) -> int:
52
+ """How many recorded turns were not clean — a multi-turn pressure gauge."""
53
+ return sum(1 for t in self.turns if not t.result.is_safe or t.result.threats)
54
+
55
+ def recent_text(self, n: int = 5, direction: Direction | None = None) -> list[str]:
56
+ items = [t for t in self.turns if direction is None or t.direction == direction]
57
+ return [t.text for t in list(items)[-n:]]
58
+
59
+
60
+ class ShieldedSession:
61
+ """Stateful, per-conversation wrapper around a :class:`Shield`.
62
+
63
+ Use as a context manager::
64
+
65
+ with shield.session(identity="user-42") as s:
66
+ clean = s.guard_input(user_msg)
67
+ reply = s.guard_output(model_reply)
68
+
69
+ All scans within the session share one identity (for rate limiting) and one
70
+ :class:`ConversationHistory` (for multi-turn detection), and are recorded for
71
+ inspection afterwards.
72
+ """
73
+
74
+ def __init__(
75
+ self,
76
+ shield: Shield,
77
+ *,
78
+ identity: str | None = None,
79
+ history_size: int = 50,
80
+ objective: str | None = None,
81
+ ) -> None:
82
+ self._shield = shield
83
+ self.identity = identity
84
+ # The user's stated goal for this conversation. When set (and an
85
+ # alignment judge is wired into the shield), output scans run the
86
+ # agent-trace alignment audit to catch goal hijacking.
87
+ self.objective = objective
88
+ self.history = ConversationHistory(maxlen=history_size)
89
+
90
+ def set_objective(self, objective: str) -> None:
91
+ """Set/replace the user's objective for alignment auditing."""
92
+ self.objective = objective
93
+
94
+ # -- scanning ------------------------------------------------------- #
95
+ def scan_input(self, text: str) -> ScanResult:
96
+ result = self._shield.scan(
97
+ text, direction=Direction.INPUT, identity=self.identity, history=self.history
98
+ )
99
+ self.history.add(Direction.INPUT, text, result)
100
+ return result
101
+
102
+ def scan_output(self, text: str) -> ScanResult:
103
+ result = self._shield.scan(
104
+ text,
105
+ direction=Direction.OUTPUT,
106
+ identity=self.identity,
107
+ history=self.history,
108
+ objective=self.objective,
109
+ )
110
+ self.history.add(Direction.OUTPUT, text, result)
111
+ return result
112
+
113
+ def guard_input(self, text: str) -> str:
114
+ """Scan input and return safe text, raising on a block (strict ergonomics)."""
115
+ return self._shield.guard(
116
+ text, direction=Direction.INPUT, identity=self.identity, history=self.history
117
+ )
118
+
119
+ def guard_output(self, text: str) -> str:
120
+ return self._shield.guard(
121
+ text,
122
+ direction=Direction.OUTPUT,
123
+ identity=self.identity,
124
+ history=self.history,
125
+ objective=self.objective,
126
+ )
127
+
128
+ # -- context manager ------------------------------------------------ #
129
+ def __enter__(self) -> ShieldedSession:
130
+ return self
131
+
132
+ def __exit__(
133
+ self,
134
+ exc_type: type[BaseException] | None,
135
+ exc: BaseException | None,
136
+ tb: TracebackType | None,
137
+ ) -> None:
138
+ return None