agent-memory-guard 0.2.2__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.
@@ -0,0 +1,393 @@
1
+ """MemoryGuard — runtime checkpoint between an agent and its memory store."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ from collections.abc import Iterable
6
+ from typing import Any, Callable
7
+
8
+ from agent_memory_guard.detectors.anomaly import (
9
+ RapidChangeDetector,
10
+ SizeAnomalyDetector,
11
+ )
12
+ from agent_memory_guard.detectors.base import DetectionResult, Detector
13
+ from agent_memory_guard.detectors.injection import PromptInjectionDetector
14
+ from agent_memory_guard.detectors.leakage import SensitiveDataDetector
15
+ from agent_memory_guard.detectors.protected_keys import ProtectedKeyDetector
16
+ from agent_memory_guard.events import Action, SecurityEvent, Severity
17
+ from agent_memory_guard.exceptions import IntegrityError, PolicyViolation
18
+ from agent_memory_guard.integrity import IntegrityRegistry, hash_value
19
+ from agent_memory_guard.policies.policy import Policy, merge_protected_keys
20
+ from agent_memory_guard.storage.memory_store import InMemoryStore, MemoryStore
21
+ from agent_memory_guard.storage.snapshots import Snapshot, SnapshotStore
22
+
23
+ log = logging.getLogger("agent_memory_guard")
24
+
25
+ EventHandler = Callable[[SecurityEvent], None]
26
+
27
+
28
+ class MemoryGuard:
29
+ """Wraps a memory store and screens every read/write through detectors+policy.
30
+
31
+ The guard is intentionally permissive by default: instantiating with no
32
+ arguments yields a working `MemoryGuard()` that detects threats and emits
33
+ events but does not block writes. Pass `policy=Policy.strict()` (or load
34
+ from YAML) to enable enforcement actions.
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ store: MemoryStore | None = None,
40
+ *,
41
+ policy: Policy | None = None,
42
+ detectors: Iterable[Detector] | None = None,
43
+ snapshots: SnapshotStore | None = None,
44
+ event_handlers: Iterable[EventHandler] = (),
45
+ snapshot_on_block: bool = True,
46
+ ) -> None:
47
+ self._store: MemoryStore = store if store is not None else InMemoryStore()
48
+ self._policy = policy or Policy.permissive()
49
+ self._integrity = IntegrityRegistry()
50
+ self._snapshots = snapshots if snapshots is not None else SnapshotStore()
51
+ self._handlers: list[EventHandler] = list(event_handlers)
52
+ self._events: list[SecurityEvent] = []
53
+ self._snapshot_on_block = snapshot_on_block
54
+ self._quarantine: dict[str, Any] = {}
55
+
56
+ protected = merge_protected_keys(self._policy)
57
+ self._protected_detector = ProtectedKeyDetector(protected)
58
+
59
+ if detectors is None:
60
+ self._detectors: list[Detector] = [
61
+ PromptInjectionDetector(),
62
+ SensitiveDataDetector(),
63
+ SizeAnomalyDetector(),
64
+ RapidChangeDetector(),
65
+ self._protected_detector,
66
+ ]
67
+ else:
68
+ self._detectors = list(detectors)
69
+ if not any(isinstance(d, ProtectedKeyDetector) for d in self._detectors):
70
+ self._detectors.append(self._protected_detector)
71
+
72
+ for key in self._policy.immutable_keys:
73
+ if key in self._store:
74
+ self._integrity.baseline(key, self._store.get(key))
75
+
76
+ # ---- public API ---------------------------------------------------
77
+
78
+ @property
79
+ def policy(self) -> Policy:
80
+ return self._policy
81
+
82
+ @property
83
+ def events(self) -> list[SecurityEvent]:
84
+ return list(self._events)
85
+
86
+ @property
87
+ def quarantine(self) -> dict[str, Any]:
88
+ return dict(self._quarantine)
89
+
90
+ def add_event_handler(self, handler: EventHandler) -> None:
91
+ self._handlers.append(handler)
92
+
93
+ def baseline(self, key: str, value: Any | None = None) -> str:
94
+ """Record a SHA-256 baseline for `key`. Uses current stored value if omitted."""
95
+ if value is None:
96
+ if key not in self._store:
97
+ raise KeyError(f"Cannot baseline missing key '{key}'")
98
+ value = self._store.get(key)
99
+ return self._integrity.baseline(key, value)
100
+
101
+ def verify(self, key: str) -> None:
102
+ """Raise IntegrityError if `key` no longer matches its baseline."""
103
+ if key in self._store:
104
+ self._integrity.verify(key, self._store.get(key))
105
+
106
+ def verify_all(self) -> list[str]:
107
+ """Return the list of keys whose stored value drifted from baseline."""
108
+ drifted: list[str] = []
109
+ for key in list(self._store.keys()):
110
+ try:
111
+ self._integrity.verify(key, self._store.get(key))
112
+ except IntegrityError:
113
+ drifted.append(key)
114
+ return drifted
115
+
116
+ def write(self, key: str, value: Any, *, source: str = "agent") -> Action:
117
+ """Inspect and (if policy allows) commit a write. Returns the action taken."""
118
+ committed_value = value
119
+ verdicts = self._run_detectors(key, value, operation="write")
120
+ worst = _highest_severity(verdicts)
121
+ decision = self._decide(verdicts, key=key)
122
+
123
+ if decision == Action.BLOCK:
124
+ self._emit(
125
+ detector=_blocking_detector(verdicts),
126
+ severity=worst,
127
+ action=Action.BLOCK,
128
+ operation="write",
129
+ key=key,
130
+ message=_combined_message(verdicts) or "Write blocked by policy",
131
+ metadata={"source": source},
132
+ )
133
+ if self._snapshot_on_block:
134
+ self._snapshots.capture(
135
+ self._dump_store(), label="pre-block", metadata={"key": key}
136
+ )
137
+ raise PolicyViolation(
138
+ f"Write to '{key}' blocked by policy", rule=_blocking_detector(verdicts), key=key
139
+ )
140
+
141
+ if decision == Action.QUARANTINE:
142
+ self._quarantine[key] = value
143
+ self._emit(
144
+ detector=_blocking_detector(verdicts),
145
+ severity=worst,
146
+ action=Action.QUARANTINE,
147
+ operation="write",
148
+ key=key,
149
+ message="Write quarantined for review",
150
+ metadata={"source": source},
151
+ )
152
+ return Action.QUARANTINE
153
+
154
+ if decision == Action.REDACT:
155
+ committed_value = self._redact(value)
156
+ self._emit(
157
+ detector="sensitive_data",
158
+ severity=worst,
159
+ action=Action.REDACT,
160
+ operation="write",
161
+ key=key,
162
+ message="Sensitive content redacted before write",
163
+ metadata={"source": source},
164
+ )
165
+
166
+ self._store.set(key, committed_value)
167
+
168
+ if any(self._protected_detector.matches(key) for _ in [0]): # touch matcher
169
+ pass
170
+ if key in self._policy.immutable_keys and not self._integrity.has_baseline(key):
171
+ self._integrity.baseline(key, committed_value)
172
+
173
+ if any(v.matched for v in verdicts) and decision == Action.ALLOW:
174
+ self._emit(
175
+ detector=_blocking_detector(verdicts),
176
+ severity=worst,
177
+ action=Action.ALLOW,
178
+ operation="write",
179
+ key=key,
180
+ message=_combined_message(verdicts) or "Write allowed with findings",
181
+ metadata={"source": source},
182
+ )
183
+ return decision
184
+
185
+ def read(self, key: str, default: Any = None, *, sink: str = "agent") -> Any:
186
+ """Read with integrity verification and outbound leakage screening."""
187
+ if key not in self._store:
188
+ return default
189
+
190
+ try:
191
+ self.verify(key)
192
+ except IntegrityError as exc:
193
+ self._emit(
194
+ detector="integrity",
195
+ severity=Severity.CRITICAL,
196
+ action=Action.BLOCK,
197
+ operation="read",
198
+ key=key,
199
+ message="Integrity verification failed on read",
200
+ metadata={"expected": exc.expected, "actual": exc.actual},
201
+ )
202
+ raise
203
+
204
+ value = self._store.get(key, default)
205
+ verdicts = self._run_detectors(key, value, operation="read")
206
+ decision = self._decide(verdicts, key=key)
207
+ worst = _highest_severity(verdicts)
208
+
209
+ if decision == Action.BLOCK:
210
+ self._emit(
211
+ detector=_blocking_detector(verdicts),
212
+ severity=worst,
213
+ action=Action.BLOCK,
214
+ operation="read",
215
+ key=key,
216
+ message="Read blocked by policy",
217
+ metadata={"sink": sink},
218
+ )
219
+ raise PolicyViolation(f"Read of '{key}' blocked by policy", key=key)
220
+
221
+ if decision == Action.REDACT:
222
+ value = self._redact(value)
223
+ self._emit(
224
+ detector="sensitive_data",
225
+ severity=worst,
226
+ action=Action.REDACT,
227
+ operation="read",
228
+ key=key,
229
+ message="Sensitive content redacted on read",
230
+ metadata={"sink": sink},
231
+ )
232
+ elif any(v.matched for v in verdicts):
233
+ self._emit(
234
+ detector=_blocking_detector(verdicts),
235
+ severity=worst,
236
+ action=Action.ALLOW,
237
+ operation="read",
238
+ key=key,
239
+ message=_combined_message(verdicts) or "Read allowed with findings",
240
+ metadata={"sink": sink},
241
+ )
242
+ return value
243
+
244
+ def delete(self, key: str) -> None:
245
+ if self._protected_detector.matches(key):
246
+ self._emit(
247
+ detector="protected_key",
248
+ severity=Severity.CRITICAL,
249
+ action=Action.BLOCK,
250
+ operation="delete",
251
+ key=key,
252
+ message=f"Delete of protected key '{key}' blocked",
253
+ )
254
+ raise PolicyViolation(f"Delete of '{key}' blocked", key=key)
255
+ self._store.delete(key)
256
+ self._integrity.clear(key)
257
+
258
+ # ---- snapshots ----------------------------------------------------
259
+
260
+ def snapshot(self, label: str = "manual") -> Snapshot:
261
+ return self._snapshots.capture(self._dump_store(), label=label)
262
+
263
+ def list_snapshots(self) -> list[Snapshot]:
264
+ return self._snapshots.list()
265
+
266
+ def rollback(self, snapshot_id: str | None = None) -> Snapshot:
267
+ """Restore the store to a known-good snapshot (latest if id omitted)."""
268
+ snap = (
269
+ self._snapshots.get(snapshot_id)
270
+ if snapshot_id
271
+ else self._snapshots.latest()
272
+ )
273
+ if snap is None:
274
+ raise LookupError("No snapshot available for rollback")
275
+
276
+ for key in list(self._store.keys()):
277
+ self._store.delete(key)
278
+ for key, value in snap.data.items():
279
+ self._store.set(key, value)
280
+
281
+ self._emit(
282
+ detector="rollback",
283
+ severity=Severity.HIGH,
284
+ action=Action.ALLOW,
285
+ operation="rollback",
286
+ key="*",
287
+ message=f"Rolled back to snapshot {snap.snapshot_id} ({snap.label})",
288
+ metadata={"snapshot_id": snap.snapshot_id, "digest": snap.digest},
289
+ )
290
+ return snap
291
+
292
+ # ---- internals ----------------------------------------------------
293
+
294
+ def _run_detectors(
295
+ self, key: str, value: Any, *, operation: str
296
+ ) -> list[DetectionResult]:
297
+ results: list[DetectionResult] = []
298
+ for detector in self._detectors:
299
+ try:
300
+ result = detector.inspect(key, value, operation=operation)
301
+ except Exception: # detectors must never break the agent
302
+ log.exception("Detector %s raised", getattr(detector, "name", detector))
303
+ continue
304
+ if result.matched:
305
+ results.append(result)
306
+ return results
307
+
308
+ def _decide(self, verdicts: list[DetectionResult], *, key: str) -> Action:
309
+ if not verdicts:
310
+ return Action.ALLOW
311
+ chosen = Action.ALLOW
312
+ for verdict in verdicts:
313
+ action = self._policy.decide(verdict.detector, verdict.severity, key)
314
+ chosen = _escalate(chosen, action)
315
+ return chosen
316
+
317
+ def _redact(self, value: Any) -> Any:
318
+ for detector in self._detectors:
319
+ if isinstance(detector, SensitiveDataDetector):
320
+ return detector.redact(value)
321
+ return value
322
+
323
+ def _emit(
324
+ self,
325
+ *,
326
+ detector: str,
327
+ severity: Severity,
328
+ action: Action,
329
+ operation: str,
330
+ key: str,
331
+ message: str,
332
+ metadata: dict[str, Any] | None = None,
333
+ ) -> None:
334
+ event = SecurityEvent(
335
+ detector=detector,
336
+ severity=severity,
337
+ action=action,
338
+ operation=operation,
339
+ key=key,
340
+ message=message,
341
+ metadata=dict(metadata or {}),
342
+ )
343
+ self._events.append(event)
344
+ for handler in self._handlers:
345
+ try:
346
+ handler(event)
347
+ except Exception:
348
+ log.exception("Event handler raised")
349
+
350
+ def _dump_store(self) -> dict[str, Any]:
351
+ if hasattr(self._store, "snapshot"):
352
+ return self._store.snapshot() # type: ignore[no-any-return]
353
+ return {k: v for k, v in self._store.items()}
354
+
355
+
356
+ _ACTION_RANK = {
357
+ Action.ALLOW: 0,
358
+ Action.REDACT: 1,
359
+ Action.QUARANTINE: 2,
360
+ Action.BLOCK: 3,
361
+ }
362
+
363
+
364
+ def _escalate(current: Action, candidate: Action) -> Action:
365
+ return candidate if _ACTION_RANK[candidate] > _ACTION_RANK[current] else current
366
+
367
+
368
+ _SEVERITY_RANK = {
369
+ Severity.INFO: 0,
370
+ Severity.LOW: 1,
371
+ Severity.MEDIUM: 2,
372
+ Severity.HIGH: 3,
373
+ Severity.CRITICAL: 4,
374
+ }
375
+
376
+
377
+ def _highest_severity(verdicts: list[DetectionResult]) -> Severity:
378
+ if not verdicts:
379
+ return Severity.INFO
380
+ return max(verdicts, key=lambda v: _SEVERITY_RANK[v.severity]).severity
381
+
382
+
383
+ def _blocking_detector(verdicts: list[DetectionResult]) -> str:
384
+ if not verdicts:
385
+ return "policy"
386
+ return max(verdicts, key=lambda v: _SEVERITY_RANK[v.severity]).detector
387
+
388
+
389
+ def _combined_message(verdicts: list[DetectionResult]) -> str:
390
+ return "; ".join(v.message for v in verdicts if v.message)
391
+
392
+
393
+ __all__ = ["MemoryGuard", "hash_value"]
@@ -0,0 +1,3 @@
1
+ from agent_memory_guard.integrations.langchain import GuardedChatMessageHistory
2
+
3
+ __all__ = ["GuardedChatMessageHistory"]
@@ -0,0 +1,96 @@
1
+ """LangChain drop-in: a chat message history guarded by MemoryGuard.
2
+
3
+ Usable with `langchain_core.chat_history.BaseChatMessageHistory` if installed,
4
+ otherwise as a standalone object exposing the same minimal contract.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+ from agent_memory_guard.events import Action
11
+ from agent_memory_guard.exceptions import PolicyViolation
12
+ from agent_memory_guard.guard import MemoryGuard
13
+
14
+ try: # pragma: no cover - optional dependency
15
+ from langchain_core.chat_history import BaseChatMessageHistory # type: ignore
16
+ from langchain_core.messages import ( # type: ignore
17
+ BaseMessage,
18
+ messages_from_dict,
19
+ messages_to_dict,
20
+ )
21
+
22
+ _HAS_LANGCHAIN = True
23
+ except Exception: # pragma: no cover - optional dependency
24
+ BaseChatMessageHistory = object # type: ignore[assignment, misc]
25
+ BaseMessage = Any # type: ignore[assignment, misc]
26
+ _HAS_LANGCHAIN = False
27
+
28
+ def messages_from_dict(d): # type: ignore[no-redef]
29
+ return list(d)
30
+
31
+ def messages_to_dict(m): # type: ignore[no-redef]
32
+ return list(m)
33
+
34
+
35
+ class GuardedChatMessageHistory(BaseChatMessageHistory): # type: ignore[misc, valid-type]
36
+ """Chat history whose messages are screened by a MemoryGuard before storage.
37
+
38
+ Each message is written under the key ``messages.<session_id>.<index>``,
39
+ so injection / leakage / size / churn detectors apply per-message.
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ session_id: str,
45
+ guard: MemoryGuard | None = None,
46
+ *,
47
+ drop_blocked: bool = True,
48
+ ) -> None:
49
+ self.session_id = session_id
50
+ self.guard = guard or MemoryGuard()
51
+ self._drop_blocked = drop_blocked
52
+ self._index_key = f"messages.{session_id}.__count__"
53
+ if self._index_key not in [k for k, _ in self._iter_store()]:
54
+ self.guard.write(self._index_key, 0, source="langchain")
55
+
56
+ @property
57
+ def messages(self) -> list[Any]:
58
+ count = self.guard.read(self._index_key, 0, sink="langchain") or 0
59
+ out: list[Any] = []
60
+ for i in range(int(count)):
61
+ key = f"messages.{self.session_id}.{i}"
62
+ try:
63
+ raw = self.guard.read(key, sink="langchain")
64
+ except PolicyViolation:
65
+ continue
66
+ if raw is None:
67
+ continue
68
+ out.extend(messages_from_dict([raw]))
69
+ return out
70
+
71
+ def add_message(self, message: Any) -> None:
72
+ count = int(self.guard.read(self._index_key, 0, sink="langchain") or 0)
73
+ key = f"messages.{self.session_id}.{count}"
74
+ payload = messages_to_dict([message])[0]
75
+ try:
76
+ decision = self.guard.write(key, payload, source="langchain")
77
+ except PolicyViolation:
78
+ if self._drop_blocked:
79
+ return
80
+ raise
81
+ if decision == Action.QUARANTINE:
82
+ return
83
+ self.guard.write(self._index_key, count + 1, source="langchain")
84
+
85
+ def clear(self) -> None:
86
+ count = int(self.guard.read(self._index_key, 0, sink="langchain") or 0)
87
+ for i in range(count):
88
+ try:
89
+ self.guard.delete(f"messages.{self.session_id}.{i}")
90
+ except PolicyViolation:
91
+ pass
92
+ self.guard.write(self._index_key, 0, source="langchain")
93
+
94
+ def _iter_store(self):
95
+ store = self.guard._store # noqa: SLF001 - intentional internal access
96
+ return list(store.items())
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from typing import Any
6
+
7
+ from agent_memory_guard.exceptions import IntegrityError
8
+
9
+
10
+ def canonical_serialize(value: Any) -> bytes:
11
+ """Stable JSON serialization for hashing.
12
+
13
+ sort_keys ensures dicts hash identically regardless of insertion order;
14
+ separators strip whitespace so formatting changes don't break baselines.
15
+ """
16
+ return json.dumps(
17
+ value, sort_keys=True, separators=(",", ":"), default=str, ensure_ascii=False
18
+ ).encode("utf-8")
19
+
20
+
21
+ def hash_value(value: Any) -> str:
22
+ """SHA-256 hex digest of a memory value."""
23
+ return hashlib.sha256(canonical_serialize(value)).hexdigest()
24
+
25
+
26
+ class IntegrityRegistry:
27
+ """Tracks SHA-256 baselines for keys flagged as protected/immutable."""
28
+
29
+ def __init__(self) -> None:
30
+ self._baselines: dict[str, str] = {}
31
+
32
+ def baseline(self, key: str, value: Any) -> str:
33
+ digest = hash_value(value)
34
+ self._baselines[key] = digest
35
+ return digest
36
+
37
+ def has_baseline(self, key: str) -> bool:
38
+ return key in self._baselines
39
+
40
+ def expected(self, key: str) -> str | None:
41
+ return self._baselines.get(key)
42
+
43
+ def verify(self, key: str, value: Any) -> None:
44
+ expected = self._baselines.get(key)
45
+ if expected is None:
46
+ return
47
+ actual = hash_value(value)
48
+ if actual != expected:
49
+ raise IntegrityError(
50
+ f"Integrity check failed for key '{key}'",
51
+ key=key,
52
+ expected=expected,
53
+ actual=actual,
54
+ )
55
+
56
+ def clear(self, key: str | None = None) -> None:
57
+ if key is None:
58
+ self._baselines.clear()
59
+ else:
60
+ self._baselines.pop(key, None)
@@ -0,0 +1,3 @@
1
+ from agent_memory_guard.policies.policy import Policy, PolicyRule, load_policy
2
+
3
+ __all__ = ["Policy", "PolicyRule", "load_policy"]