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,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
+ )
@@ -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