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.
- agent_memory_guard/__init__.py +24 -0
- agent_memory_guard/detectors/__init__.py +18 -0
- agent_memory_guard/detectors/anomaly.py +89 -0
- agent_memory_guard/detectors/base.py +23 -0
- agent_memory_guard/detectors/injection.py +69 -0
- agent_memory_guard/detectors/leakage.py +70 -0
- agent_memory_guard/detectors/protected_keys.py +45 -0
- agent_memory_guard/events.py +50 -0
- agent_memory_guard/exceptions.py +24 -0
- agent_memory_guard/guard.py +393 -0
- agent_memory_guard/integrations/__init__.py +3 -0
- agent_memory_guard/integrations/langchain.py +96 -0
- agent_memory_guard/integrity.py +60 -0
- agent_memory_guard/policies/__init__.py +3 -0
- agent_memory_guard/policies/policy.py +162 -0
- agent_memory_guard/storage/__init__.py +4 -0
- agent_memory_guard/storage/memory_store.py +53 -0
- agent_memory_guard/storage/snapshots.py +73 -0
- agent_memory_guard-0.2.2.dist-info/METADATA +176 -0
- agent_memory_guard-0.2.2.dist-info/RECORD +23 -0
- agent_memory_guard-0.2.2.dist-info/WHEEL +5 -0
- agent_memory_guard-0.2.2.dist-info/licenses/LICENSE.md +1 -0
- agent_memory_guard-0.2.2.dist-info/top_level.txt +1 -0
|
@@ -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,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)
|