plug-play-memory 0.2.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.
@@ -0,0 +1,374 @@
1
+ """
2
+ MCP server for plug-play-memory — security-hardened.
3
+
4
+ Changes from v0.1:
5
+ - Size limits on inputs/outputs/raw (H1 fix) — 100KB max per event
6
+ - Cap on recall/audit_recent limit parameter (H2 fix)
7
+ - Rate limiting: 100 events/min per agent_id (H3 fix)
8
+ - Removed dead "all" scope check (L4 fix)
9
+ - agent_type passed as parameter to policy.check (M5 fix)
10
+ - Audit chain verification tool added (C4 fix)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import os
18
+ import sys
19
+ import threading
20
+ import time
21
+ from collections import defaultdict
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from mcp.server.fastmcp import FastMCP
26
+
27
+ from .auth import AuthConfig, AuthContext, AuthError, ALL_SCOPES
28
+ from .consolidation import Consolidator
29
+ from .emitter import EventEmitter
30
+ from .judge import Judge
31
+ from .policy import PolicyEngine
32
+ from .storage import MemoryStore
33
+
34
+
35
+ # ─── Security limits (H1, H2, H3 fixes) ────────────────────────────────────
36
+
37
+ MAX_EVENT_SIZE = 100 * 1024 # 100 KB total per event (H1)
38
+ MAX_FIELD_SIZE = 10 * 1024 # 10 KB per individual field (H1)
39
+ MAX_RECALL_LIMIT = 1000 # cap on recall/audit_recent limit (H2)
40
+ MAX_QUERY_LENGTH = 1000 # cap on recall query length (H2)
41
+
42
+ RATE_LIMIT_WINDOW = 60 # seconds (H3)
43
+ RATE_LIMIT_MAX = 100 # max events per agent per window (H3)
44
+
45
+
46
+ class RateLimiter:
47
+ """Simple in-memory rate limiter, per agent_id (H3 fix)."""
48
+
49
+ def __init__(self, max_calls: int = RATE_LIMIT_MAX, window_sec: int = RATE_LIMIT_WINDOW):
50
+ self.max_calls = max_calls
51
+ self.window_sec = window_sec
52
+ self._calls: dict[str, list[float]] = defaultdict(list)
53
+ self._lock = threading.Lock()
54
+
55
+ def check(self, agent_id: str) -> bool:
56
+ """Returns True if call is allowed, False if rate-limited."""
57
+ now = time.time()
58
+ with self._lock:
59
+ recent = [t for t in self._calls[agent_id] if now - t < self.window_sec]
60
+ if len(recent) >= self.max_calls:
61
+ self._calls[agent_id] = recent
62
+ return False
63
+ recent.append(now)
64
+ self._calls[agent_id] = recent
65
+ return True
66
+
67
+
68
+ # ─── Helpers ────────────────────────────────────────────────────────────────
69
+
70
+ def _require_scope(ctx: AuthContext, scope: str) -> None:
71
+ """Raise PermissionError if ctx lacks the required scope (L4 fix: removed 'all' check)."""
72
+ if scope not in ctx.scopes:
73
+ raise PermissionError(
74
+ f"missing required scope: {scope} (have: {ctx.scopes})"
75
+ )
76
+
77
+
78
+ def _check_event_size(action: str, inputs: dict, outputs: dict, raw: str) -> str | None:
79
+ """Returns error message if event exceeds size limits, else None (H1 fix)."""
80
+ if len(str(action)) > MAX_FIELD_SIZE:
81
+ return f"action exceeds {MAX_FIELD_SIZE} bytes"
82
+ if len(str(raw)) > MAX_FIELD_SIZE:
83
+ return f"raw exceeds {MAX_FIELD_SIZE} bytes"
84
+ if len(json.dumps(inputs, default=str)) > MAX_FIELD_SIZE:
85
+ return f"inputs exceed {MAX_FIELD_SIZE} bytes"
86
+ if len(json.dumps(outputs, default=str)) > MAX_FIELD_SIZE:
87
+ return f"outputs exceed {MAX_FIELD_SIZE} bytes"
88
+ total = (len(str(action)) + len(str(raw)) +
89
+ len(json.dumps(inputs, default=str)) +
90
+ len(json.dumps(outputs, default=str)))
91
+ if total > MAX_EVENT_SIZE:
92
+ return f"total event size {total} exceeds {MAX_EVENT_SIZE} bytes"
93
+ return None
94
+
95
+
96
+ # ─── Server builder ─────────────────────────────────────────────────────────
97
+
98
+ def build_server(
99
+ db_path: str,
100
+ policy_dir: str | None = None,
101
+ custom_policy_dir: str | None = None,
102
+ auth_config_path: str | None = None,
103
+ encryption_key: str | None = None,
104
+ audit_signing_key: str | None = None,
105
+ ) -> FastMCP:
106
+ """Build the MCP server with all tools registered."""
107
+
108
+ store = MemoryStore(db_path, encryption_key=encryption_key)
109
+ policy = PolicyEngine(custom_dir=custom_policy_dir)
110
+ judge = Judge()
111
+ consolidator = Consolidator(store)
112
+ auth_config = AuthConfig(auth_config_path)
113
+ rate_limiter = RateLimiter()
114
+
115
+ if auth_config.default_agent_type:
116
+ try:
117
+ policy.set_agent_type(auth_config.default_agent_id,
118
+ auth_config.default_agent_type)
119
+ except Exception:
120
+ pass # invalid default — will be caught on first request
121
+
122
+ server = FastMCP("plug-play-memory")
123
+
124
+ def get_auth() -> AuthContext:
125
+ return auth_config.authenticate(token=None)
126
+
127
+ def get_emitter(ctx: AuthContext) -> EventEmitter:
128
+ return EventEmitter(
129
+ store=store, policy=policy, judge=judge,
130
+ agent_id=ctx.agent_id, agent_type=ctx.agent_type,
131
+ )
132
+
133
+ # ─── TOOL: remember_event (with H1 size limits + H3 rate limiting) ──────
134
+ @server.tool(description=(
135
+ "Record a meaningful event for the current agent. The system judges "
136
+ "whether the event is worth saving, checks policy, deduplicates, and "
137
+ "writes to memory if appropriate.\n\n"
138
+ "Size limits: 100KB total per event, 10KB per field.\n"
139
+ "Rate limit: 100 events/min per agent."
140
+ ))
141
+ def remember_event(
142
+ action: str,
143
+ inputs: dict[str, Any] | None = None,
144
+ outputs: dict[str, Any] | None = None,
145
+ outcome: str = "success",
146
+ raw: str = "",
147
+ ) -> dict[str, Any]:
148
+ ctx = get_auth()
149
+ _require_scope(ctx, "memory:write")
150
+
151
+ # H1: size check
152
+ err = _check_event_size(action, inputs or {}, outputs or {}, raw)
153
+ if err:
154
+ return {"saved": False, "reason": f"size limit: {err}"}
155
+
156
+ # H3: rate limit
157
+ if not rate_limiter.check(ctx.agent_id):
158
+ return {"saved": False, "reason": "rate limited (100/min)"}
159
+
160
+ policy.set_agent_type(ctx.agent_id, ctx.agent_type)
161
+ emitter = get_emitter(ctx)
162
+ fact = emitter.emit(
163
+ action=action,
164
+ inputs=inputs or {},
165
+ outputs=outputs or {},
166
+ outcome=outcome,
167
+ raw=raw,
168
+ )
169
+ if fact is None:
170
+ return {
171
+ "saved": False,
172
+ "reason": "discarded by judge or blocked by policy or deduplicated",
173
+ }
174
+ return {
175
+ "saved": True,
176
+ "fact_id": fact.id,
177
+ "tier": fact.tier,
178
+ "key": fact.key,
179
+ "importance": fact.importance,
180
+ "salience_score": round(fact.salience_score, 3),
181
+ "tagged": fact.tagged,
182
+ "summary": fact.value,
183
+ }
184
+
185
+ # ─── TOOL: recall (H2: capped limit + query length) ─────────────────────
186
+ @server.tool(description=(
187
+ "Retrieve relevant memory for the current agent.\n\n"
188
+ " tier='core' → always-injected facts (small, fast)\n"
189
+ " tier='recall' → most recent conversation-style memories\n"
190
+ " tier='archival' → semantic search over long-term memory (default)\n\n"
191
+ "Limit capped at 1000. Query length capped at 1000 chars."
192
+ ))
193
+ def recall(
194
+ query: str = "",
195
+ tier: str = "archival",
196
+ limit: int = 5,
197
+ ) -> dict[str, Any]:
198
+ ctx = get_auth()
199
+ _require_scope(ctx, "memory:read")
200
+ policy.set_agent_type(ctx.agent_id, ctx.agent_type)
201
+
202
+ # H2: cap limit and query length
203
+ limit = max(1, min(limit, MAX_RECALL_LIMIT))
204
+ query = (query or "")[:MAX_QUERY_LENGTH]
205
+
206
+ core = [{"id": f.id, "key": f.key, "value": f.value,
207
+ "importance": f.importance}
208
+ for f in store.get_core(ctx.agent_id)]
209
+
210
+ if tier == "core":
211
+ results = []
212
+ elif tier == "recall":
213
+ results = [{"id": f.id, "key": f.key, "value": f.value,
214
+ "importance": f.importance,
215
+ "created_at": f.created_at}
216
+ for f in store.recall_recent(ctx.agent_id, limit=limit)]
217
+ else:
218
+ facts = store.recall_semantic(ctx.agent_id, query, k=limit)
219
+ for f in facts:
220
+ consolidator.reinforce_on_recall(f.id)
221
+ results = [{"id": f.id, "key": f.key, "value": f.value,
222
+ "importance": f.importance,
223
+ "salience_score": round(f.salience_score, 3),
224
+ "recall_count": f.recall_count}
225
+ for f in facts]
226
+
227
+ store.audit.log(ctx.agent_id, "recall",
228
+ {"tier": tier, "query": query, "limit": limit,
229
+ "results_count": len(results)})
230
+ return {"core": core, "results": results, "tier": tier}
231
+
232
+ @server.tool(description=(
233
+ "Check whether an action is allowed for the current agent BEFORE "
234
+ "executing it. This is the hard enforcement gate."
235
+ ))
236
+ def check_permission(
237
+ action: str,
238
+ context: dict[str, Any] | None = None,
239
+ ) -> dict[str, Any]:
240
+ ctx = get_auth()
241
+ _require_scope(ctx, "permission:check")
242
+ # M5 fix: pass agent_type explicitly
243
+ decision = policy.check(
244
+ ctx.agent_id, action, context or {},
245
+ agent_type=ctx.agent_type,
246
+ )
247
+ store.audit.log(
248
+ ctx.agent_id, "permission_check",
249
+ {"action": action, "context": context or {},
250
+ "backend": "layered_yaml", "reason": decision.reason,
251
+ "layers": decision.layers_evaluated},
252
+ allowed=decision.allowed,
253
+ )
254
+ return {
255
+ "allowed": decision.allowed,
256
+ "reason": decision.reason,
257
+ "layers_evaluated": decision.layers_evaluated,
258
+ }
259
+
260
+ @server.tool(description=(
261
+ "Soft-delete a specific fact by id. Hard delete requires admin scope."
262
+ ))
263
+ def forget(fact_id: str, hard: bool = False) -> dict[str, Any]:
264
+ ctx = get_auth()
265
+ _require_scope(ctx, "memory:delete")
266
+ if hard:
267
+ _require_scope(ctx, "admin:consolidate")
268
+ removed = store.forget(ctx.agent_id, fact_id, hard=hard)
269
+ return {"removed": removed, "hard": hard}
270
+
271
+ @server.tool(description=(
272
+ "Return the most recent audit log entries for the current agent."
273
+ ))
274
+ def audit_recent(limit: int = 20) -> dict[str, Any]:
275
+ ctx = get_auth()
276
+ _require_scope(ctx, "admin:audit")
277
+ limit = max(1, min(limit, MAX_RECALL_LIMIT)) # H2
278
+ entries = store.audit.recent(ctx.agent_id, limit=limit)
279
+ return {
280
+ "agent_id": ctx.agent_id,
281
+ "entries": [
282
+ {"id": e.id, "op_type": e.op_type,
283
+ "detail": e.detail, "allowed": e.allowed,
284
+ "timestamp": e.timestamp}
285
+ for e in entries
286
+ ],
287
+ "count": len(entries),
288
+ }
289
+
290
+ @server.tool(description=(
291
+ "Return the coverage manifest for the current agent: which actions "
292
+ "have been checked at least once, and the allow/deny split. This is "
293
+ "deliberately about what HAS happened, not a safety claim."
294
+ ))
295
+ def coverage_manifest() -> dict[str, Any]:
296
+ ctx = get_auth()
297
+ _require_scope(ctx, "admin:audit")
298
+ return store.audit.coverage_manifest(ctx.agent_id)
299
+
300
+ @server.tool(description=(
301
+ "Run the consolidation sweep: promote tagged facts that have been "
302
+ "recalled, decay the rest, prune low-importance facts not recalled "
303
+ "in 30 days. This is the brain's 'sleep' phase."
304
+ ))
305
+ def consolidate() -> dict[str, Any]:
306
+ ctx = get_auth()
307
+ _require_scope(ctx, "admin:consolidate")
308
+ c_stats = consolidator.consolidate_tagged(ctx.agent_id)
309
+ f_stats = consolidator.forget_unused(ctx.agent_id)
310
+ return {"consolidation": c_stats, "forgetting": f_stats}
311
+
312
+ @server.tool(description=(
313
+ "List all policy layers currently loaded for the system, plus the "
314
+ "action whitelist for this agent's type."
315
+ ))
316
+ def list_policies() -> dict[str, Any]:
317
+ ctx = get_auth()
318
+ _require_scope(ctx, "admin:audit")
319
+ return {
320
+ "agent_id": ctx.agent_id,
321
+ "agent_type": ctx.agent_type,
322
+ "layers": policy.list_layers(),
323
+ "allowed_actions": sorted(policy.list_allowed_actions(ctx.agent_type)),
324
+ }
325
+
326
+ @server.tool(description=(
327
+ "Verify the integrity of the audit log chain (C4 fix). Returns "
328
+ "counts of valid/invalid signatures and any chain breaks.\n\n"
329
+ "Call this periodically to detect tampering. If invalid_signatures "
330
+ "or chain_broken_at is non-empty, the audit log has been tampered with."
331
+ ))
332
+ def verify_audit_integrity() -> dict[str, Any]:
333
+ ctx = get_auth()
334
+ _require_scope(ctx, "admin:audit")
335
+ return store.audit.verify_chain()
336
+
337
+ return server
338
+
339
+
340
+ def run() -> None:
341
+ parser = argparse.ArgumentParser(
342
+ description="Run the plug-play-memory MCP server (stdio transport).",
343
+ )
344
+ parser.add_argument("--db", default="memory.db",
345
+ help="Path to the SQLite memory file.")
346
+ parser.add_argument("--policies", default=None,
347
+ help="Path to custom policy directory.")
348
+ parser.add_argument("--config", default=None,
349
+ help="Path to auth config YAML.")
350
+ parser.add_argument("--agent-id", default=None)
351
+ parser.add_argument("--agent-type", default=None)
352
+ parser.add_argument("--encryption-key", default=None,
353
+ help="SQLCipher encryption key (recommended for production).")
354
+ parser.add_argument("--audit-key", default=None,
355
+ help="HMAC signing key for audit log (recommended for production).")
356
+ args = parser.parse_args()
357
+
358
+ if args.agent_id:
359
+ os.environ["AGENTMEMORY_AGENT_ID"] = args.agent_id
360
+ if args.agent_type:
361
+ os.environ["AGENTMEMORY_AGENT_TYPE"] = args.agent_type
362
+
363
+ server = build_server(
364
+ db_path=args.db,
365
+ custom_policy_dir=args.policies,
366
+ auth_config_path=args.config,
367
+ encryption_key=args.encryption_key or os.environ.get("AGENTMEMORY_DB_KEY"),
368
+ audit_signing_key=args.audit_key or os.environ.get("AGENTMEMORY_AUDIT_KEY"),
369
+ )
370
+ server.run(transport="stdio")
371
+
372
+
373
+ if __name__ == "__main__":
374
+ run()