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.
- plug_play_memory/__init__.py +23 -0
- plug_play_memory/audit.py +209 -0
- plug_play_memory/auth.py +155 -0
- plug_play_memory/cli.py +413 -0
- plug_play_memory/consolidation.py +172 -0
- plug_play_memory/emitter.py +337 -0
- plug_play_memory/events.py +36 -0
- plug_play_memory/judge.py +461 -0
- plug_play_memory/policies/builtin/_base.yaml +18 -0
- plug_play_memory/policies/builtin/coding.yaml +53 -0
- plug_play_memory/policies/builtin/financial.yaml +50 -0
- plug_play_memory/policies/builtin/study.yaml +50 -0
- plug_play_memory/policy.py +353 -0
- plug_play_memory/server.py +374 -0
- plug_play_memory/storage.py +495 -0
- plug_play_memory-0.2.0.dist-info/METADATA +487 -0
- plug_play_memory-0.2.0.dist-info/RECORD +21 -0
- plug_play_memory-0.2.0.dist-info/WHEEL +5 -0
- plug_play_memory-0.2.0.dist-info/entry_points.txt +2 -0
- plug_play_memory-0.2.0.dist-info/licenses/LICENSE +201 -0
- plug_play_memory-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""plug-play-memory: self-hosted, brain-inspired memory + permissions for AI agents."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.2.0"
|
|
4
|
+
|
|
5
|
+
from .storage import MemoryStore, Fact
|
|
6
|
+
from .audit import AuditLog, AuditEntry
|
|
7
|
+
from .policy import PolicyEngine, PolicyDecision
|
|
8
|
+
from .events import AgentEvent
|
|
9
|
+
from .judge import Judge, JudgeDecision, RulesJudge, HeuristicEnricher, SalienceScorer
|
|
10
|
+
from .emitter import EventEmitter
|
|
11
|
+
from .consolidation import Consolidator
|
|
12
|
+
from .auth import AuthConfig, AuthContext, AuthError, ALL_SCOPES
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"MemoryStore", "Fact",
|
|
16
|
+
"AuditLog", "AuditEntry",
|
|
17
|
+
"PolicyEngine", "PolicyDecision",
|
|
18
|
+
"AgentEvent",
|
|
19
|
+
"Judge", "JudgeDecision", "RulesJudge", "HeuristicEnricher", "SalienceScorer",
|
|
20
|
+
"EventEmitter",
|
|
21
|
+
"Consolidator",
|
|
22
|
+
"AuthConfig", "AuthContext", "AuthError", "ALL_SCOPES",
|
|
23
|
+
]
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Audit log for plug-play-memory — security-hardened version.
|
|
3
|
+
|
|
4
|
+
Changes from v0.1:
|
|
5
|
+
- HMAC-signed entries (C4 fix) — appended fake entries fail signature check
|
|
6
|
+
- External chain tip verification method (C4 fix)
|
|
7
|
+
- Constructor accepts optional signing_key (passed from MemoryStore)
|
|
8
|
+
- Triggers still block UPDATE/DELETE on rows
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import hmac
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import sqlite3
|
|
18
|
+
import time
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Any, Callable
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class AuditEntry:
|
|
25
|
+
id: int
|
|
26
|
+
agent_id: str
|
|
27
|
+
op_type: str
|
|
28
|
+
detail: dict[str, Any]
|
|
29
|
+
allowed: bool | None
|
|
30
|
+
timestamp: float
|
|
31
|
+
prev_hash: str | None
|
|
32
|
+
entry_hash: str
|
|
33
|
+
signature: str | None # new in v2
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AuditLog:
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
get_conn: Callable[[], sqlite3.Connection],
|
|
40
|
+
owner: Any = None,
|
|
41
|
+
signing_key: str | None = None,
|
|
42
|
+
):
|
|
43
|
+
self._get_conn = get_conn
|
|
44
|
+
# If no key provided, derive one from the OS (random per process).
|
|
45
|
+
# This means signatures are unverifiable across restarts unless user
|
|
46
|
+
# provides a stable key — which is exactly the security property we want.
|
|
47
|
+
if signing_key:
|
|
48
|
+
self._signing_key = signing_key.encode()
|
|
49
|
+
else:
|
|
50
|
+
self._signing_key = os.urandom(32)
|
|
51
|
+
self._init_schema()
|
|
52
|
+
|
|
53
|
+
def _init_schema(self) -> None:
|
|
54
|
+
conn = self._get_conn()
|
|
55
|
+
conn.executescript(
|
|
56
|
+
"""
|
|
57
|
+
CREATE TABLE IF NOT EXISTS audit_log (
|
|
58
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
59
|
+
agent_id TEXT NOT NULL,
|
|
60
|
+
op_type TEXT NOT NULL,
|
|
61
|
+
detail TEXT NOT NULL,
|
|
62
|
+
allowed INTEGER,
|
|
63
|
+
timestamp REAL NOT NULL,
|
|
64
|
+
prev_hash TEXT,
|
|
65
|
+
entry_hash TEXT NOT NULL,
|
|
66
|
+
signature TEXT
|
|
67
|
+
);
|
|
68
|
+
CREATE INDEX IF NOT EXISTS idx_audit_agent_time
|
|
69
|
+
ON audit_log(agent_id, timestamp);
|
|
70
|
+
CREATE INDEX IF NOT EXISTS idx_audit_op
|
|
71
|
+
ON audit_log(op_type, timestamp);
|
|
72
|
+
|
|
73
|
+
CREATE TRIGGER IF NOT EXISTS audit_no_update
|
|
74
|
+
BEFORE UPDATE ON audit_log
|
|
75
|
+
BEGIN
|
|
76
|
+
SELECT RAISE(ABORT, 'audit_log is append-only');
|
|
77
|
+
END;
|
|
78
|
+
CREATE TRIGGER IF NOT EXISTS audit_no_delete
|
|
79
|
+
BEFORE DELETE ON audit_log
|
|
80
|
+
BEGIN
|
|
81
|
+
SELECT RAISE(ABORT, 'audit_log is append-only');
|
|
82
|
+
END;
|
|
83
|
+
"""
|
|
84
|
+
)
|
|
85
|
+
conn.commit()
|
|
86
|
+
|
|
87
|
+
def _sign(self, payload: str) -> str:
|
|
88
|
+
return hmac.new(self._signing_key, payload.encode(), hashlib.sha256).hexdigest()
|
|
89
|
+
|
|
90
|
+
def log(
|
|
91
|
+
self,
|
|
92
|
+
agent_id: str,
|
|
93
|
+
op_type: str,
|
|
94
|
+
detail: dict[str, Any],
|
|
95
|
+
allowed: bool | None = None,
|
|
96
|
+
) -> None:
|
|
97
|
+
conn = self._get_conn()
|
|
98
|
+
now = time.time()
|
|
99
|
+
prev = conn.execute(
|
|
100
|
+
"SELECT entry_hash FROM audit_log ORDER BY id DESC LIMIT 1"
|
|
101
|
+
).fetchone()
|
|
102
|
+
prev_hash = prev["entry_hash"] if prev else None
|
|
103
|
+
# Build the payload that determines entry_hash
|
|
104
|
+
hash_payload = (
|
|
105
|
+
f"{agent_id}|{op_type}|{json.dumps(detail, sort_keys=True)}|"
|
|
106
|
+
f"{allowed}|{now}|{prev_hash}"
|
|
107
|
+
)
|
|
108
|
+
entry_hash = hashlib.blake2b(hash_payload.encode(), digest_size=16).hexdigest()
|
|
109
|
+
# Sign the entry_hash with HMAC (C4 fix)
|
|
110
|
+
signature = self._sign(entry_hash)
|
|
111
|
+
|
|
112
|
+
conn.execute(
|
|
113
|
+
"""INSERT INTO audit_log
|
|
114
|
+
(agent_id, op_type, detail, allowed, timestamp, prev_hash, entry_hash, signature)
|
|
115
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
116
|
+
(
|
|
117
|
+
agent_id, op_type, json.dumps(detail, sort_keys=True),
|
|
118
|
+
1 if allowed else (0 if allowed is False else None),
|
|
119
|
+
now, prev_hash, entry_hash, signature,
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
conn.commit()
|
|
123
|
+
|
|
124
|
+
def recent(self, agent_id: str, limit: int = 50) -> list[AuditEntry]:
|
|
125
|
+
limit = max(1, min(limit, 1000)) # H2 fix
|
|
126
|
+
rows = self._get_conn().execute(
|
|
127
|
+
"SELECT * FROM audit_log WHERE agent_id = ? ORDER BY id DESC LIMIT ?",
|
|
128
|
+
(agent_id, limit),
|
|
129
|
+
).fetchall()
|
|
130
|
+
return [self._row_to_entry(r) for r in rows]
|
|
131
|
+
|
|
132
|
+
def recent_all(self, limit: int = 100) -> list[AuditEntry]:
|
|
133
|
+
limit = max(1, min(limit, 1000)) # H2 fix
|
|
134
|
+
rows = self._get_conn().execute(
|
|
135
|
+
"SELECT * FROM audit_log ORDER BY id DESC LIMIT ?", (limit,)
|
|
136
|
+
).fetchall()
|
|
137
|
+
return [self._row_to_entry(r) for r in rows]
|
|
138
|
+
|
|
139
|
+
def coverage_manifest(self, agent_id: str) -> dict[str, Any]:
|
|
140
|
+
rows = self._get_conn().execute(
|
|
141
|
+
"""SELECT detail, allowed FROM audit_log
|
|
142
|
+
WHERE agent_id = ? AND op_type = 'permission_check'""",
|
|
143
|
+
(agent_id,),
|
|
144
|
+
).fetchall()
|
|
145
|
+
|
|
146
|
+
by_action: dict[str, dict[str, int]] = {}
|
|
147
|
+
for r in rows:
|
|
148
|
+
action = json.loads(r["detail"]).get("action", "unknown")
|
|
149
|
+
bucket = by_action.setdefault(action, {"allowed": 0, "denied": 0})
|
|
150
|
+
bucket["allowed" if r["allowed"] else "denied"] += 1
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
"agent_id": agent_id,
|
|
154
|
+
"actions_checked": list(by_action.keys()),
|
|
155
|
+
"counts_by_action": by_action,
|
|
156
|
+
"total_permission_checks": len(rows),
|
|
157
|
+
"note": "Reflects only actions actually attempted — not a safety claim.",
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
def verify_chain(self) -> dict[str, Any]:
|
|
161
|
+
"""
|
|
162
|
+
Verify the integrity of the audit chain (C4 fix).
|
|
163
|
+
|
|
164
|
+
Returns a report with:
|
|
165
|
+
- total_entries: count
|
|
166
|
+
- valid_signatures: entries whose signature matches our HMAC key
|
|
167
|
+
- invalid_signatures: entries with bad signatures (likely tampered)
|
|
168
|
+
- chain_broken: entries whose prev_hash doesn't match previous entry_hash
|
|
169
|
+
"""
|
|
170
|
+
rows = self._get_conn().execute(
|
|
171
|
+
"SELECT id, entry_hash, prev_hash, signature FROM audit_log ORDER BY id"
|
|
172
|
+
).fetchall()
|
|
173
|
+
|
|
174
|
+
valid = 0
|
|
175
|
+
invalid_sigs = []
|
|
176
|
+
broken_chain = []
|
|
177
|
+
prev_hash = None
|
|
178
|
+
|
|
179
|
+
for r in rows:
|
|
180
|
+
# Check signature
|
|
181
|
+
expected_sig = self._sign(r["entry_hash"])
|
|
182
|
+
if r["signature"] and hmac.compare_digest(expected_sig, r["signature"]):
|
|
183
|
+
valid += 1
|
|
184
|
+
else:
|
|
185
|
+
invalid_sigs.append(r["id"])
|
|
186
|
+
# Check chain linkage
|
|
187
|
+
if prev_hash is not None and r["prev_hash"] != prev_hash:
|
|
188
|
+
broken_chain.append(r["id"])
|
|
189
|
+
prev_hash = r["entry_hash"]
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
"total_entries": len(rows),
|
|
193
|
+
"valid_signatures": valid,
|
|
194
|
+
"invalid_signatures": invalid_sigs,
|
|
195
|
+
"chain_broken_at": broken_chain,
|
|
196
|
+
"integrity_ok": (not invalid_sigs and not broken_chain),
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
@staticmethod
|
|
200
|
+
def _row_to_entry(r: sqlite3.Row) -> AuditEntry:
|
|
201
|
+
allowed_raw = r["allowed"]
|
|
202
|
+
return AuditEntry(
|
|
203
|
+
id=r["id"], agent_id=r["agent_id"], op_type=r["op_type"],
|
|
204
|
+
detail=json.loads(r["detail"]),
|
|
205
|
+
allowed=bool(allowed_raw) if allowed_raw is not None else None,
|
|
206
|
+
timestamp=r["timestamp"],
|
|
207
|
+
prev_hash=r["prev_hash"], entry_hash=r["entry_hash"],
|
|
208
|
+
signature=r["signature"],
|
|
209
|
+
)
|
plug_play_memory/auth.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Auth layer for plug-play-memory MCP server — security-hardened.
|
|
3
|
+
|
|
4
|
+
Changes from v0.1:
|
|
5
|
+
- Tokens stored as SHA-256 hashes (H4 fix) — plaintext tokens never persisted
|
|
6
|
+
- Config schema validation with friendly errors (was KeyError before)
|
|
7
|
+
- Reserved agent_id validation (M4 fix)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import os
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import yaml
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class AuthContext:
|
|
23
|
+
"""The identity of the caller, established at connection time."""
|
|
24
|
+
agent_id: str
|
|
25
|
+
agent_type: str
|
|
26
|
+
scopes: list[str]
|
|
27
|
+
token_id: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
ALL_SCOPES = {
|
|
31
|
+
"memory:read",
|
|
32
|
+
"memory:write",
|
|
33
|
+
"memory:delete",
|
|
34
|
+
"permission:check",
|
|
35
|
+
"admin:consolidate",
|
|
36
|
+
"admin:audit",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AuthError(Exception):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _hash_token(token: str) -> str:
|
|
45
|
+
"""SHA-256 hash of a token. Stored at rest instead of plaintext (H4 fix)."""
|
|
46
|
+
return hashlib.sha256(token.encode()).hexdigest()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AuthConfig:
|
|
50
|
+
"""
|
|
51
|
+
Loads auth config from YAML. Tokens can be specified in two ways:
|
|
52
|
+
|
|
53
|
+
1. Plaintext (convenient for first-time setup, but stored as hash internally):
|
|
54
|
+
tokens:
|
|
55
|
+
- id: token-coder-1
|
|
56
|
+
token: sk-coder-1-abc123...
|
|
57
|
+
|
|
58
|
+
2. Pre-hashed (recommended for production):
|
|
59
|
+
tokens:
|
|
60
|
+
- id: token-coder-1
|
|
61
|
+
token_hash: 5f2b09c... # sha256(token)
|
|
62
|
+
|
|
63
|
+
Either form works. Plaintext is auto-hashed on load.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, config_path: str | Path | None = None):
|
|
67
|
+
self.config_path = Path(config_path) if config_path else None
|
|
68
|
+
self.enabled = True
|
|
69
|
+
self.default_agent_id = "local"
|
|
70
|
+
self.default_agent_type = "coding"
|
|
71
|
+
self.default_scopes = ["memory:read", "memory:write", "permission:check"]
|
|
72
|
+
self._tokens: dict[str, dict[str, Any]] = {} # token_hash -> {id, agent_id, agent_type, scopes}
|
|
73
|
+
if self.config_path and self.config_path.exists():
|
|
74
|
+
self._load()
|
|
75
|
+
|
|
76
|
+
def _load(self) -> None:
|
|
77
|
+
try:
|
|
78
|
+
spec = yaml.safe_load(self.config_path.read_text()) or {}
|
|
79
|
+
except yaml.YAMLError as e:
|
|
80
|
+
raise AuthError(f"Invalid YAML in config: {e}")
|
|
81
|
+
auth = spec.get("auth", {})
|
|
82
|
+
self.enabled = auth.get("enabled", True)
|
|
83
|
+
self.default_agent_id = auth.get("default_agent_id", "local")
|
|
84
|
+
self.default_agent_type = auth.get("default_agent_type", "coding")
|
|
85
|
+
self.default_scopes = auth.get("default_scopes",
|
|
86
|
+
["memory:read", "memory:write", "permission:check"])
|
|
87
|
+
|
|
88
|
+
# Validate default agent_id isn't reserved (M4 fix)
|
|
89
|
+
from .storage import validate_agent_id, RESERVED_AGENT_IDS
|
|
90
|
+
try:
|
|
91
|
+
validate_agent_id(self.default_agent_id)
|
|
92
|
+
except ValueError as e:
|
|
93
|
+
raise AuthError(
|
|
94
|
+
f"config.yaml: default_agent_id '{self.default_agent_id}' is reserved. "
|
|
95
|
+
f"Reserved: {sorted(RESERVED_AGENT_IDS)}"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
for i, t in enumerate(auth.get("tokens", []) or []):
|
|
99
|
+
# Schema validation (was missing in v0.1)
|
|
100
|
+
if "id" not in t:
|
|
101
|
+
raise AuthError(f"config.yaml: tokens[{i}] missing 'id' field")
|
|
102
|
+
if "agent_id" not in t:
|
|
103
|
+
raise AuthError(f"config.yaml: tokens[{i}] missing 'agent_id' field")
|
|
104
|
+
try:
|
|
105
|
+
validate_agent_id(t["agent_id"])
|
|
106
|
+
except ValueError as e:
|
|
107
|
+
raise AuthError(f"config.yaml: tokens[{i}].agent_id: {e}")
|
|
108
|
+
|
|
109
|
+
# Accept either plaintext 'token' or pre-hashed 'token_hash' (H4 fix)
|
|
110
|
+
if "token_hash" in t:
|
|
111
|
+
token_hash = t["token_hash"]
|
|
112
|
+
elif "token" in t:
|
|
113
|
+
token_hash = _hash_token(t["token"])
|
|
114
|
+
else:
|
|
115
|
+
raise AuthError(
|
|
116
|
+
f"config.yaml: tokens[{i}] must have 'token' or 'token_hash'"
|
|
117
|
+
)
|
|
118
|
+
self._tokens[token_hash] = {
|
|
119
|
+
"id": t["id"],
|
|
120
|
+
"agent_id": t["agent_id"],
|
|
121
|
+
"agent_type": t.get("agent_type", "default"),
|
|
122
|
+
"scopes": t.get("scopes", self.default_scopes),
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
def authenticate(self, token: str | None = None) -> AuthContext:
|
|
126
|
+
if not self.enabled:
|
|
127
|
+
return AuthContext(
|
|
128
|
+
agent_id=self.default_agent_id,
|
|
129
|
+
agent_type=self.default_agent_type,
|
|
130
|
+
scopes=list(ALL_SCOPES),
|
|
131
|
+
token_id="disabled",
|
|
132
|
+
)
|
|
133
|
+
if not token:
|
|
134
|
+
return AuthContext(
|
|
135
|
+
agent_id=os.environ.get("AGENTMEMORY_AGENT_ID", self.default_agent_id),
|
|
136
|
+
agent_type=os.environ.get("AGENTMEMORY_AGENT_TYPE",
|
|
137
|
+
self.default_agent_type),
|
|
138
|
+
scopes=self.default_scopes,
|
|
139
|
+
token_id="default",
|
|
140
|
+
)
|
|
141
|
+
# Hash the incoming token and compare (H4 fix — constant-time via dict lookup)
|
|
142
|
+
token_hash = _hash_token(token)
|
|
143
|
+
if token_hash not in self._tokens:
|
|
144
|
+
raise AuthError("unknown token")
|
|
145
|
+
t = self._tokens[token_hash]
|
|
146
|
+
return AuthContext(
|
|
147
|
+
agent_id=t["agent_id"],
|
|
148
|
+
agent_type=t["agent_type"],
|
|
149
|
+
scopes=t["scopes"],
|
|
150
|
+
token_id=t["id"],
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
@staticmethod
|
|
154
|
+
def has_scope(ctx: AuthContext, scope: str) -> bool:
|
|
155
|
+
return scope in ctx.scopes
|