superlocalmemory 2.7.6 → 2.8.0
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.
- package/CHANGELOG.md +120 -155
- package/README.md +115 -89
- package/api_server.py +2 -12
- package/docs/PATTERN-LEARNING.md +64 -199
- package/docs/example_graph_usage.py +4 -6
- package/install.sh +59 -0
- package/mcp_server.py +83 -7
- package/package.json +1 -8
- package/scripts/generate-thumbnails.py +3 -5
- package/skills/slm-build-graph/SKILL.md +1 -1
- package/skills/slm-list-recent/SKILL.md +1 -1
- package/skills/slm-recall/SKILL.md +1 -1
- package/skills/slm-remember/SKILL.md +1 -1
- package/skills/slm-show-patterns/SKILL.md +1 -1
- package/skills/slm-status/SKILL.md +1 -1
- package/skills/slm-switch-profile/SKILL.md +1 -1
- package/src/agent_registry.py +7 -18
- package/src/auth_middleware.py +3 -5
- package/src/auto_backup.py +3 -7
- package/src/behavioral/__init__.py +49 -0
- package/src/behavioral/behavioral_listener.py +203 -0
- package/src/behavioral/behavioral_patterns.py +275 -0
- package/src/behavioral/cross_project_transfer.py +206 -0
- package/src/behavioral/outcome_inference.py +194 -0
- package/src/behavioral/outcome_tracker.py +193 -0
- package/src/behavioral/tests/__init__.py +4 -0
- package/src/behavioral/tests/test_behavioral_integration.py +108 -0
- package/src/behavioral/tests/test_behavioral_patterns.py +150 -0
- package/src/behavioral/tests/test_cross_project_transfer.py +142 -0
- package/src/behavioral/tests/test_mcp_behavioral.py +139 -0
- package/src/behavioral/tests/test_mcp_report_outcome.py +117 -0
- package/src/behavioral/tests/test_outcome_inference.py +107 -0
- package/src/behavioral/tests/test_outcome_tracker.py +96 -0
- package/src/cache_manager.py +4 -6
- package/src/compliance/__init__.py +48 -0
- package/src/compliance/abac_engine.py +149 -0
- package/src/compliance/abac_middleware.py +116 -0
- package/src/compliance/audit_db.py +215 -0
- package/src/compliance/audit_logger.py +148 -0
- package/src/compliance/retention_manager.py +289 -0
- package/src/compliance/retention_scheduler.py +186 -0
- package/src/compliance/tests/__init__.py +4 -0
- package/src/compliance/tests/test_abac_enforcement.py +95 -0
- package/src/compliance/tests/test_abac_engine.py +124 -0
- package/src/compliance/tests/test_abac_mcp_integration.py +118 -0
- package/src/compliance/tests/test_audit_db.py +123 -0
- package/src/compliance/tests/test_audit_logger.py +98 -0
- package/src/compliance/tests/test_mcp_audit.py +128 -0
- package/src/compliance/tests/test_mcp_retention_policy.py +125 -0
- package/src/compliance/tests/test_retention_manager.py +131 -0
- package/src/compliance/tests/test_retention_scheduler.py +99 -0
- package/src/db_connection_manager.py +2 -12
- package/src/embedding_engine.py +61 -669
- package/src/embeddings/__init__.py +47 -0
- package/src/embeddings/cache.py +70 -0
- package/src/embeddings/cli.py +113 -0
- package/src/embeddings/constants.py +47 -0
- package/src/embeddings/database.py +91 -0
- package/src/embeddings/engine.py +247 -0
- package/src/embeddings/model_loader.py +145 -0
- package/src/event_bus.py +3 -13
- package/src/graph/__init__.py +36 -0
- package/src/graph/build_helpers.py +74 -0
- package/src/graph/cli.py +87 -0
- package/src/graph/cluster_builder.py +188 -0
- package/src/graph/cluster_summary.py +148 -0
- package/src/graph/constants.py +47 -0
- package/src/graph/edge_builder.py +162 -0
- package/src/graph/entity_extractor.py +95 -0
- package/src/graph/graph_core.py +226 -0
- package/src/graph/graph_search.py +231 -0
- package/src/graph/hierarchical.py +207 -0
- package/src/graph/schema.py +99 -0
- package/src/graph_engine.py +45 -1451
- package/src/hnsw_index.py +3 -7
- package/src/hybrid_search.py +36 -683
- package/src/learning/__init__.py +27 -12
- package/src/learning/adaptive_ranker.py +50 -12
- package/src/learning/cross_project_aggregator.py +2 -12
- package/src/learning/engagement_tracker.py +2 -12
- package/src/learning/feature_extractor.py +175 -43
- package/src/learning/feedback_collector.py +7 -12
- package/src/learning/learning_db.py +180 -12
- package/src/learning/project_context_manager.py +2 -12
- package/src/learning/source_quality_scorer.py +2 -12
- package/src/learning/synthetic_bootstrap.py +2 -12
- package/src/learning/tests/__init__.py +2 -0
- package/src/learning/tests/test_adaptive_ranker.py +2 -6
- package/src/learning/tests/test_adaptive_ranker_v28.py +60 -0
- package/src/learning/tests/test_aggregator.py +2 -6
- package/src/learning/tests/test_auto_retrain_v28.py +35 -0
- package/src/learning/tests/test_e2e_ranking_v28.py +82 -0
- package/src/learning/tests/test_feature_extractor_v28.py +93 -0
- package/src/learning/tests/test_feedback_collector.py +2 -6
- package/src/learning/tests/test_learning_db.py +2 -6
- package/src/learning/tests/test_learning_db_v28.py +110 -0
- package/src/learning/tests/test_learning_init_v28.py +48 -0
- package/src/learning/tests/test_outcome_signals.py +48 -0
- package/src/learning/tests/test_project_context.py +2 -6
- package/src/learning/tests/test_schema_migration.py +319 -0
- package/src/learning/tests/test_signal_inference.py +11 -13
- package/src/learning/tests/test_source_quality.py +2 -6
- package/src/learning/tests/test_synthetic_bootstrap.py +3 -7
- package/src/learning/tests/test_workflow_miner.py +2 -6
- package/src/learning/workflow_pattern_miner.py +2 -12
- package/src/lifecycle/__init__.py +54 -0
- package/src/lifecycle/bounded_growth.py +239 -0
- package/src/lifecycle/compaction_engine.py +226 -0
- package/src/lifecycle/lifecycle_engine.py +302 -0
- package/src/lifecycle/lifecycle_evaluator.py +225 -0
- package/src/lifecycle/lifecycle_scheduler.py +130 -0
- package/src/lifecycle/retention_policy.py +285 -0
- package/src/lifecycle/tests/__init__.py +4 -0
- package/src/lifecycle/tests/test_bounded_growth.py +193 -0
- package/src/lifecycle/tests/test_compaction.py +179 -0
- package/src/lifecycle/tests/test_lifecycle_engine.py +137 -0
- package/src/lifecycle/tests/test_lifecycle_evaluation.py +177 -0
- package/src/lifecycle/tests/test_lifecycle_scheduler.py +127 -0
- package/src/lifecycle/tests/test_lifecycle_search.py +109 -0
- package/src/lifecycle/tests/test_mcp_compact.py +149 -0
- package/src/lifecycle/tests/test_mcp_lifecycle_status.py +114 -0
- package/src/lifecycle/tests/test_retention_policy.py +162 -0
- package/src/mcp_tools_v28.py +280 -0
- package/src/memory-profiles.py +2 -12
- package/src/memory-reset.py +2 -12
- package/src/memory_compression.py +2 -12
- package/src/memory_store_v2.py +76 -20
- package/src/migrate_v1_to_v2.py +2 -12
- package/src/pattern_learner.py +29 -975
- package/src/patterns/__init__.py +24 -0
- package/src/patterns/analyzers.py +247 -0
- package/src/patterns/learner.py +267 -0
- package/src/patterns/scoring.py +167 -0
- package/src/patterns/store.py +223 -0
- package/src/patterns/terminology.py +138 -0
- package/src/provenance_tracker.py +4 -14
- package/src/query_optimizer.py +4 -6
- package/src/rate_limiter.py +2 -6
- package/src/search/__init__.py +20 -0
- package/src/search/cli.py +77 -0
- package/src/search/constants.py +26 -0
- package/src/search/engine.py +239 -0
- package/src/search/fusion.py +122 -0
- package/src/search/index_loader.py +112 -0
- package/src/search/methods.py +162 -0
- package/src/search_engine_v2.py +4 -6
- package/src/setup_validator.py +7 -13
- package/src/subscription_manager.py +2 -12
- package/src/tree/__init__.py +59 -0
- package/src/tree/builder.py +183 -0
- package/src/tree/nodes.py +196 -0
- package/src/tree/queries.py +252 -0
- package/src/tree/schema.py +76 -0
- package/src/tree_manager.py +10 -711
- package/src/trust/__init__.py +45 -0
- package/src/trust/constants.py +66 -0
- package/src/trust/queries.py +157 -0
- package/src/trust/schema.py +95 -0
- package/src/trust/scorer.py +299 -0
- package/src/trust/signals.py +95 -0
- package/src/trust_scorer.py +39 -697
- package/src/webhook_dispatcher.py +2 -12
- package/ui/app.js +1 -1
- package/ui/js/agents.js +1 -1
- package/ui_server.py +2 -14
- package/ATTRIBUTION.md +0 -140
- package/docs/ARCHITECTURE-V2.5.md +0 -190
- package/docs/GRAPH-ENGINE.md +0 -503
- package/docs/architecture-diagram.drawio +0 -405
- package/docs/plans/2026-02-13-benchmark-suite.md +0 -1349
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 SuperLocalMemory (superlocalmemory.com)
|
|
3
|
+
"""Background scheduler for periodic retention policy enforcement.
|
|
4
|
+
|
|
5
|
+
Runs on a configurable interval (default: 24 hours) to:
|
|
6
|
+
1. Load compliance retention rules from audit.db
|
|
7
|
+
2. Scan all memories in memory.db against those rules
|
|
8
|
+
3. Tombstone expired memories (age exceeds retention_days)
|
|
9
|
+
4. Log every action to audit.db for tamper-evident compliance
|
|
10
|
+
|
|
11
|
+
Uses daemon threading -- does not prevent process exit.
|
|
12
|
+
"""
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import sqlite3
|
|
16
|
+
import threading
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from typing import Any, Dict, List, Optional
|
|
19
|
+
|
|
20
|
+
from .retention_manager import ComplianceRetentionManager
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
# Default interval: 24 hours
|
|
25
|
+
DEFAULT_INTERVAL_SECONDS = 86400
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RetentionScheduler:
|
|
29
|
+
"""Background scheduler for periodic retention policy enforcement.
|
|
30
|
+
|
|
31
|
+
Orchestrates ComplianceRetentionManager on a configurable timer
|
|
32
|
+
interval to automatically enforce retention rules.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
memory_db_path: str,
|
|
38
|
+
audit_db_path: str,
|
|
39
|
+
interval_seconds: int = DEFAULT_INTERVAL_SECONDS,
|
|
40
|
+
):
|
|
41
|
+
self._memory_db_path = memory_db_path
|
|
42
|
+
self._audit_db_path = audit_db_path
|
|
43
|
+
self.interval_seconds = interval_seconds
|
|
44
|
+
|
|
45
|
+
self._manager = ComplianceRetentionManager(
|
|
46
|
+
memory_db_path, audit_db_path,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
self._timer: Optional[threading.Timer] = None
|
|
50
|
+
self._running = False
|
|
51
|
+
self._lock = threading.Lock()
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def is_running(self) -> bool:
|
|
55
|
+
"""Whether the scheduler is currently running."""
|
|
56
|
+
return self._running
|
|
57
|
+
|
|
58
|
+
def start(self) -> None:
|
|
59
|
+
"""Start the background scheduler."""
|
|
60
|
+
with self._lock:
|
|
61
|
+
if self._running:
|
|
62
|
+
return
|
|
63
|
+
self._running = True
|
|
64
|
+
self._schedule_next()
|
|
65
|
+
|
|
66
|
+
def stop(self) -> None:
|
|
67
|
+
"""Stop the background scheduler."""
|
|
68
|
+
with self._lock:
|
|
69
|
+
self._running = False
|
|
70
|
+
if self._timer is not None:
|
|
71
|
+
self._timer.cancel()
|
|
72
|
+
self._timer = None
|
|
73
|
+
|
|
74
|
+
def run_now(self) -> Dict[str, Any]:
|
|
75
|
+
"""Execute a retention enforcement cycle immediately.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Dict with timestamp, actions taken, and rules evaluated.
|
|
79
|
+
"""
|
|
80
|
+
return self._execute_cycle()
|
|
81
|
+
|
|
82
|
+
def _schedule_next(self) -> None:
|
|
83
|
+
"""Schedule the next enforcement cycle."""
|
|
84
|
+
self._timer = threading.Timer(self.interval_seconds, self._run_cycle)
|
|
85
|
+
self._timer.daemon = True
|
|
86
|
+
self._timer.start()
|
|
87
|
+
|
|
88
|
+
def _run_cycle(self) -> None:
|
|
89
|
+
"""Run one enforcement cycle, then schedule the next."""
|
|
90
|
+
try:
|
|
91
|
+
self._execute_cycle()
|
|
92
|
+
except Exception:
|
|
93
|
+
pass # Scheduler must not crash
|
|
94
|
+
finally:
|
|
95
|
+
with self._lock:
|
|
96
|
+
if self._running:
|
|
97
|
+
self._schedule_next()
|
|
98
|
+
|
|
99
|
+
def _execute_cycle(self) -> Dict[str, Any]:
|
|
100
|
+
"""Core retention enforcement logic.
|
|
101
|
+
|
|
102
|
+
1. Load all retention rules from audit.db
|
|
103
|
+
2. Scan every memory against each rule
|
|
104
|
+
3. Tombstone memories that exceed retention_days
|
|
105
|
+
4. Log actions to audit.db
|
|
106
|
+
"""
|
|
107
|
+
rules = self._manager.list_rules()
|
|
108
|
+
actions: List[Dict[str, Any]] = []
|
|
109
|
+
|
|
110
|
+
# Scan all memories
|
|
111
|
+
memory_ids = self._get_all_memory_ids()
|
|
112
|
+
|
|
113
|
+
for mem_id in memory_ids:
|
|
114
|
+
mem = self._get_memory(mem_id)
|
|
115
|
+
if mem is None:
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
# Already tombstoned -- skip
|
|
119
|
+
if mem.get("lifecycle_state") == "tombstoned":
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
match = self._manager.evaluate_memory(mem_id)
|
|
123
|
+
if match is None:
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
# Check if memory age exceeds the rule's retention_days
|
|
127
|
+
created_at = mem.get("created_at")
|
|
128
|
+
if created_at is None:
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
age_days = self._age_in_days(created_at)
|
|
132
|
+
if age_days > match["retention_days"]:
|
|
133
|
+
action = match["action"]
|
|
134
|
+
if action == "tombstone":
|
|
135
|
+
result = self._manager.execute_erasure_request(
|
|
136
|
+
mem_id, match["framework"], "retention_scheduler",
|
|
137
|
+
)
|
|
138
|
+
actions.append({
|
|
139
|
+
"memory_id": mem_id,
|
|
140
|
+
"action": action,
|
|
141
|
+
"rule_name": match["rule_name"],
|
|
142
|
+
"framework": match["framework"],
|
|
143
|
+
"age_days": age_days,
|
|
144
|
+
"success": result.get("success", False),
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
149
|
+
"actions": actions,
|
|
150
|
+
"rules_evaluated": len(rules),
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
# ------------------------------------------------------------------
|
|
154
|
+
# Internal helpers
|
|
155
|
+
# ------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
def _get_all_memory_ids(self) -> List[int]:
|
|
158
|
+
"""Return all memory IDs from memory.db."""
|
|
159
|
+
conn = sqlite3.connect(self._memory_db_path)
|
|
160
|
+
try:
|
|
161
|
+
rows = conn.execute("SELECT id FROM memories").fetchall()
|
|
162
|
+
return [r[0] for r in rows]
|
|
163
|
+
finally:
|
|
164
|
+
conn.close()
|
|
165
|
+
|
|
166
|
+
def _get_memory(self, memory_id: int) -> Optional[Dict[str, Any]]:
|
|
167
|
+
"""Fetch a single memory row as a dict."""
|
|
168
|
+
conn = sqlite3.connect(self._memory_db_path)
|
|
169
|
+
conn.row_factory = sqlite3.Row
|
|
170
|
+
try:
|
|
171
|
+
row = conn.execute(
|
|
172
|
+
"SELECT * FROM memories WHERE id = ?", (memory_id,),
|
|
173
|
+
).fetchone()
|
|
174
|
+
return dict(row) if row else None
|
|
175
|
+
finally:
|
|
176
|
+
conn.close()
|
|
177
|
+
|
|
178
|
+
@staticmethod
|
|
179
|
+
def _age_in_days(created_at_str: str) -> float:
|
|
180
|
+
"""Calculate age of a memory in days from its created_at."""
|
|
181
|
+
try:
|
|
182
|
+
created = datetime.fromisoformat(created_at_str)
|
|
183
|
+
now = datetime.now(created.tzinfo)
|
|
184
|
+
return (now - created).total_seconds() / 86400
|
|
185
|
+
except (ValueError, TypeError):
|
|
186
|
+
return 0.0
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 SuperLocalMemory (superlocalmemory.com)
|
|
3
|
+
"""Tests for ABAC enforcement in memory operations.
|
|
4
|
+
"""
|
|
5
|
+
import sqlite3
|
|
6
|
+
import tempfile
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import json
|
|
10
|
+
import shutil
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TestABACEnforcement:
|
|
17
|
+
def setup_method(self):
|
|
18
|
+
self.tmp_dir = tempfile.mkdtemp()
|
|
19
|
+
self.db_path = os.path.join(self.tmp_dir, "memory.db")
|
|
20
|
+
# Create store with test data
|
|
21
|
+
from memory_store_v2 import MemoryStoreV2
|
|
22
|
+
self.store = MemoryStoreV2(self.db_path)
|
|
23
|
+
self.store.add_memory(
|
|
24
|
+
content="public memory about Python",
|
|
25
|
+
tags=["python"],
|
|
26
|
+
importance=5,
|
|
27
|
+
)
|
|
28
|
+
self.store.add_memory(
|
|
29
|
+
content="private memory about secrets",
|
|
30
|
+
tags=["secrets"],
|
|
31
|
+
importance=8,
|
|
32
|
+
)
|
|
33
|
+
# Set access_level for private memory
|
|
34
|
+
conn = sqlite3.connect(self.db_path)
|
|
35
|
+
conn.execute("UPDATE memories SET access_level='private' WHERE id=2")
|
|
36
|
+
conn.commit()
|
|
37
|
+
conn.close()
|
|
38
|
+
self.store._rebuild_vectors()
|
|
39
|
+
|
|
40
|
+
def teardown_method(self):
|
|
41
|
+
shutil.rmtree(self.tmp_dir, ignore_errors=True)
|
|
42
|
+
|
|
43
|
+
def test_search_without_abac_works(self):
|
|
44
|
+
"""Search without ABAC context works (backward compat)."""
|
|
45
|
+
results = self.store.search("Python", limit=5)
|
|
46
|
+
assert len(results) >= 1
|
|
47
|
+
|
|
48
|
+
def test_search_with_agent_context(self):
|
|
49
|
+
"""Search with agent_context parameter works."""
|
|
50
|
+
results = self.store.search(
|
|
51
|
+
"memory", limit=10, agent_context={"agent_id": "user"}
|
|
52
|
+
)
|
|
53
|
+
assert isinstance(results, list)
|
|
54
|
+
|
|
55
|
+
def test_create_without_abac_works(self):
|
|
56
|
+
"""Create without ABAC works (backward compat)."""
|
|
57
|
+
mem_id = self.store.add_memory(content="new memory", tags=["test"])
|
|
58
|
+
assert mem_id is not None
|
|
59
|
+
|
|
60
|
+
def test_abac_check_method_exists(self):
|
|
61
|
+
"""MemoryStoreV2 has _check_abac method."""
|
|
62
|
+
assert hasattr(self.store, "_check_abac")
|
|
63
|
+
|
|
64
|
+
def test_check_abac_default_allows(self):
|
|
65
|
+
"""Default ABAC check (no policy file) allows everything."""
|
|
66
|
+
result = self.store._check_abac(
|
|
67
|
+
subject={"agent_id": "user"},
|
|
68
|
+
resource={"access_level": "public"},
|
|
69
|
+
action="read",
|
|
70
|
+
)
|
|
71
|
+
assert result["allowed"] is True
|
|
72
|
+
|
|
73
|
+
def test_check_abac_with_policy(self):
|
|
74
|
+
"""ABAC check with policy file respects deny rules."""
|
|
75
|
+
policy_path = os.path.join(self.tmp_dir, "abac_policies.json")
|
|
76
|
+
with open(policy_path, "w") as f:
|
|
77
|
+
json.dump(
|
|
78
|
+
[
|
|
79
|
+
{
|
|
80
|
+
"name": "deny-private",
|
|
81
|
+
"effect": "deny",
|
|
82
|
+
"subjects": {"agent_id": "*"},
|
|
83
|
+
"resources": {"access_level": "private"},
|
|
84
|
+
"actions": ["read"],
|
|
85
|
+
}
|
|
86
|
+
],
|
|
87
|
+
f,
|
|
88
|
+
)
|
|
89
|
+
result = self.store._check_abac(
|
|
90
|
+
subject={"agent_id": "bot"},
|
|
91
|
+
resource={"access_level": "private"},
|
|
92
|
+
action="read",
|
|
93
|
+
policy_path=policy_path,
|
|
94
|
+
)
|
|
95
|
+
assert result["allowed"] is False
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 SuperLocalMemory (superlocalmemory.com)
|
|
3
|
+
"""Tests for ABAC policy engine.
|
|
4
|
+
"""
|
|
5
|
+
import tempfile
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestABACEngine:
|
|
15
|
+
def setup_method(self):
|
|
16
|
+
self.tmp_dir = tempfile.mkdtemp()
|
|
17
|
+
self.policy_path = os.path.join(self.tmp_dir, "abac_policies.json")
|
|
18
|
+
|
|
19
|
+
def teardown_method(self):
|
|
20
|
+
import shutil
|
|
21
|
+
shutil.rmtree(self.tmp_dir, ignore_errors=True)
|
|
22
|
+
|
|
23
|
+
def _write_policies(self, policies):
|
|
24
|
+
with open(self.policy_path, "w") as f:
|
|
25
|
+
json.dump(policies, f)
|
|
26
|
+
|
|
27
|
+
def test_creation_no_policy_file(self):
|
|
28
|
+
"""Engine works with no policy file — allow all."""
|
|
29
|
+
from compliance.abac_engine import ABACEngine
|
|
30
|
+
engine = ABACEngine(config_path="/nonexistent/path.json")
|
|
31
|
+
assert engine is not None
|
|
32
|
+
|
|
33
|
+
def test_missing_policy_allows_all(self):
|
|
34
|
+
"""No policy file → all access allowed (backward compat)."""
|
|
35
|
+
from compliance.abac_engine import ABACEngine
|
|
36
|
+
engine = ABACEngine(config_path="/nonexistent/path.json")
|
|
37
|
+
result = engine.evaluate(subject={"agent_id": "user"}, resource={"access_level": "public"}, action="read")
|
|
38
|
+
assert result["allowed"] is True
|
|
39
|
+
|
|
40
|
+
def test_load_policies_from_json(self):
|
|
41
|
+
"""Can load policies from JSON file."""
|
|
42
|
+
from compliance.abac_engine import ABACEngine
|
|
43
|
+
self._write_policies([
|
|
44
|
+
{"name": "deny-private", "effect": "deny", "subjects": {"agent_id": "*"}, "resources": {"access_level": "private"}, "actions": ["read"]}
|
|
45
|
+
])
|
|
46
|
+
engine = ABACEngine(config_path=self.policy_path)
|
|
47
|
+
assert len(engine.policies) == 1
|
|
48
|
+
|
|
49
|
+
def test_deny_policy_blocks_access(self):
|
|
50
|
+
"""Deny policy prevents access to matching resources."""
|
|
51
|
+
from compliance.abac_engine import ABACEngine
|
|
52
|
+
self._write_policies([
|
|
53
|
+
{"name": "deny-private", "effect": "deny", "subjects": {"agent_id": "*"}, "resources": {"access_level": "private"}, "actions": ["read"]}
|
|
54
|
+
])
|
|
55
|
+
engine = ABACEngine(config_path=self.policy_path)
|
|
56
|
+
result = engine.evaluate(subject={"agent_id": "agent_a"}, resource={"access_level": "private"}, action="read")
|
|
57
|
+
assert result["allowed"] is False
|
|
58
|
+
assert result["policy_name"] == "deny-private"
|
|
59
|
+
|
|
60
|
+
def test_allow_policy_grants_access(self):
|
|
61
|
+
"""Allow policy explicitly permits access."""
|
|
62
|
+
from compliance.abac_engine import ABACEngine
|
|
63
|
+
self._write_policies([
|
|
64
|
+
{"name": "allow-admin", "effect": "allow", "subjects": {"agent_id": "admin"}, "resources": {"access_level": "*"}, "actions": ["read", "write", "delete"]}
|
|
65
|
+
])
|
|
66
|
+
engine = ABACEngine(config_path=self.policy_path)
|
|
67
|
+
result = engine.evaluate(subject={"agent_id": "admin"}, resource={"access_level": "private"}, action="write")
|
|
68
|
+
assert result["allowed"] is True
|
|
69
|
+
|
|
70
|
+
def test_subject_matching_specific_agent(self):
|
|
71
|
+
"""Policy matches specific agent_id."""
|
|
72
|
+
from compliance.abac_engine import ABACEngine
|
|
73
|
+
self._write_policies([
|
|
74
|
+
{"name": "deny-untrusted", "effect": "deny", "subjects": {"agent_id": "untrusted_bot"}, "resources": {"access_level": "*"}, "actions": ["read"]}
|
|
75
|
+
])
|
|
76
|
+
engine = ABACEngine(config_path=self.policy_path)
|
|
77
|
+
# untrusted_bot denied
|
|
78
|
+
r1 = engine.evaluate(subject={"agent_id": "untrusted_bot"}, resource={"access_level": "public"}, action="read")
|
|
79
|
+
assert r1["allowed"] is False
|
|
80
|
+
# trusted_agent allowed (no matching deny policy)
|
|
81
|
+
r2 = engine.evaluate(subject={"agent_id": "trusted_agent"}, resource={"access_level": "public"}, action="read")
|
|
82
|
+
assert r2["allowed"] is True
|
|
83
|
+
|
|
84
|
+
def test_resource_matching_by_project(self):
|
|
85
|
+
"""Policy matches by project name."""
|
|
86
|
+
from compliance.abac_engine import ABACEngine
|
|
87
|
+
self._write_policies([
|
|
88
|
+
{"name": "deny-secret-project", "effect": "deny", "subjects": {"agent_id": "*"}, "resources": {"project": "secret_project"}, "actions": ["read"]}
|
|
89
|
+
])
|
|
90
|
+
engine = ABACEngine(config_path=self.policy_path)
|
|
91
|
+
r1 = engine.evaluate(subject={"agent_id": "user"}, resource={"project": "secret_project"}, action="read")
|
|
92
|
+
assert r1["allowed"] is False
|
|
93
|
+
r2 = engine.evaluate(subject={"agent_id": "user"}, resource={"project": "public_project"}, action="read")
|
|
94
|
+
assert r2["allowed"] is True
|
|
95
|
+
|
|
96
|
+
def test_action_matching(self):
|
|
97
|
+
"""Policy only applies to specified actions."""
|
|
98
|
+
from compliance.abac_engine import ABACEngine
|
|
99
|
+
self._write_policies([
|
|
100
|
+
{"name": "deny-delete", "effect": "deny", "subjects": {"agent_id": "*"}, "resources": {"access_level": "*"}, "actions": ["delete"]}
|
|
101
|
+
])
|
|
102
|
+
engine = ABACEngine(config_path=self.policy_path)
|
|
103
|
+
r1 = engine.evaluate(subject={"agent_id": "user"}, resource={"access_level": "public"}, action="delete")
|
|
104
|
+
assert r1["allowed"] is False
|
|
105
|
+
r2 = engine.evaluate(subject={"agent_id": "user"}, resource={"access_level": "public"}, action="read")
|
|
106
|
+
assert r2["allowed"] is True
|
|
107
|
+
|
|
108
|
+
def test_deny_takes_precedence(self):
|
|
109
|
+
"""When both allow and deny match, deny wins."""
|
|
110
|
+
from compliance.abac_engine import ABACEngine
|
|
111
|
+
self._write_policies([
|
|
112
|
+
{"name": "allow-all", "effect": "allow", "subjects": {"agent_id": "*"}, "resources": {"access_level": "*"}, "actions": ["read"]},
|
|
113
|
+
{"name": "deny-private", "effect": "deny", "subjects": {"agent_id": "*"}, "resources": {"access_level": "private"}, "actions": ["read"]}
|
|
114
|
+
])
|
|
115
|
+
engine = ABACEngine(config_path=self.policy_path)
|
|
116
|
+
result = engine.evaluate(subject={"agent_id": "user"}, resource={"access_level": "private"}, action="read")
|
|
117
|
+
assert result["allowed"] is False
|
|
118
|
+
|
|
119
|
+
def test_evaluate_returns_reason(self):
|
|
120
|
+
"""Evaluation result includes reason."""
|
|
121
|
+
from compliance.abac_engine import ABACEngine
|
|
122
|
+
engine = ABACEngine(config_path="/nonexistent/path.json")
|
|
123
|
+
result = engine.evaluate(subject={"agent_id": "user"}, resource={}, action="read")
|
|
124
|
+
assert "reason" in result
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 SuperLocalMemory (superlocalmemory.com)
|
|
3
|
+
"""Tests for ABAC enforcement via MCP tool integration.
|
|
4
|
+
"""
|
|
5
|
+
import tempfile
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestABACMCPIntegration:
|
|
15
|
+
def setup_method(self):
|
|
16
|
+
self.tmp_dir = tempfile.mkdtemp()
|
|
17
|
+
self.db_path = os.path.join(self.tmp_dir, "memory.db")
|
|
18
|
+
|
|
19
|
+
def teardown_method(self):
|
|
20
|
+
import shutil
|
|
21
|
+
|
|
22
|
+
shutil.rmtree(self.tmp_dir, ignore_errors=True)
|
|
23
|
+
|
|
24
|
+
def test_middleware_creation(self):
|
|
25
|
+
from compliance.abac_middleware import ABACMiddleware
|
|
26
|
+
|
|
27
|
+
mw = ABACMiddleware(self.db_path)
|
|
28
|
+
assert mw is not None
|
|
29
|
+
|
|
30
|
+
def test_check_read_access_default_allow(self):
|
|
31
|
+
"""Default (no policies) allows all reads."""
|
|
32
|
+
from compliance.abac_middleware import ABACMiddleware
|
|
33
|
+
|
|
34
|
+
mw = ABACMiddleware(self.db_path)
|
|
35
|
+
result = mw.check_access(
|
|
36
|
+
agent_id="any_agent",
|
|
37
|
+
action="read",
|
|
38
|
+
resource={"access_level": "public"},
|
|
39
|
+
)
|
|
40
|
+
assert result["allowed"] is True
|
|
41
|
+
|
|
42
|
+
def test_check_write_access_default_allow(self):
|
|
43
|
+
from compliance.abac_middleware import ABACMiddleware
|
|
44
|
+
|
|
45
|
+
mw = ABACMiddleware(self.db_path)
|
|
46
|
+
result = mw.check_access(
|
|
47
|
+
agent_id="any_agent", action="write", resource={}
|
|
48
|
+
)
|
|
49
|
+
assert result["allowed"] is True
|
|
50
|
+
|
|
51
|
+
def test_check_access_with_deny_policy(self):
|
|
52
|
+
"""Deny policy blocks access when enforced."""
|
|
53
|
+
from compliance.abac_middleware import ABACMiddleware
|
|
54
|
+
|
|
55
|
+
policy_path = os.path.join(self.tmp_dir, "abac_policies.json")
|
|
56
|
+
with open(policy_path, "w") as f:
|
|
57
|
+
json.dump(
|
|
58
|
+
[
|
|
59
|
+
{
|
|
60
|
+
"name": "deny-bots",
|
|
61
|
+
"effect": "deny",
|
|
62
|
+
"subjects": {"agent_id": "untrusted_bot"},
|
|
63
|
+
"resources": {"access_level": "*"},
|
|
64
|
+
"actions": ["read", "write"],
|
|
65
|
+
}
|
|
66
|
+
],
|
|
67
|
+
f,
|
|
68
|
+
)
|
|
69
|
+
mw = ABACMiddleware(self.db_path, policy_path=policy_path)
|
|
70
|
+
result = mw.check_access(
|
|
71
|
+
agent_id="untrusted_bot",
|
|
72
|
+
action="read",
|
|
73
|
+
resource={"access_level": "public"},
|
|
74
|
+
)
|
|
75
|
+
assert result["allowed"] is False
|
|
76
|
+
|
|
77
|
+
def test_denied_access_logged(self):
|
|
78
|
+
"""Denied access is recorded for audit trail."""
|
|
79
|
+
from compliance.abac_middleware import ABACMiddleware
|
|
80
|
+
|
|
81
|
+
policy_path = os.path.join(self.tmp_dir, "abac_policies.json")
|
|
82
|
+
with open(policy_path, "w") as f:
|
|
83
|
+
json.dump(
|
|
84
|
+
[
|
|
85
|
+
{
|
|
86
|
+
"name": "deny-all-write",
|
|
87
|
+
"effect": "deny",
|
|
88
|
+
"subjects": {"agent_id": "*"},
|
|
89
|
+
"resources": {"access_level": "*"},
|
|
90
|
+
"actions": ["write"],
|
|
91
|
+
}
|
|
92
|
+
],
|
|
93
|
+
f,
|
|
94
|
+
)
|
|
95
|
+
mw = ABACMiddleware(self.db_path, policy_path=policy_path)
|
|
96
|
+
mw.check_access(agent_id="user", action="write", resource={})
|
|
97
|
+
assert mw.denied_count >= 1
|
|
98
|
+
|
|
99
|
+
def test_build_agent_context(self):
|
|
100
|
+
"""build_agent_context creates proper context dict for store."""
|
|
101
|
+
from compliance.abac_middleware import ABACMiddleware
|
|
102
|
+
|
|
103
|
+
mw = ABACMiddleware(self.db_path)
|
|
104
|
+
ctx = mw.build_agent_context(agent_id="claude_agent", protocol="mcp")
|
|
105
|
+
assert ctx["agent_id"] == "claude_agent"
|
|
106
|
+
assert ctx["protocol"] == "mcp"
|
|
107
|
+
|
|
108
|
+
def test_graceful_when_compliance_unavailable(self):
|
|
109
|
+
"""Middleware works even if ABACEngine import fails."""
|
|
110
|
+
from compliance.abac_middleware import ABACMiddleware
|
|
111
|
+
|
|
112
|
+
mw = ABACMiddleware(
|
|
113
|
+
self.db_path, policy_path="/nonexistent/path.json"
|
|
114
|
+
)
|
|
115
|
+
result = mw.check_access(
|
|
116
|
+
agent_id="user", action="read", resource={}
|
|
117
|
+
)
|
|
118
|
+
assert result["allowed"] is True
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 SuperLocalMemory (superlocalmemory.com)
|
|
3
|
+
"""Tests for audit database with hash chain tamper detection.
|
|
4
|
+
"""
|
|
5
|
+
import sqlite3
|
|
6
|
+
import tempfile
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TestAuditDB:
|
|
17
|
+
def setup_method(self):
|
|
18
|
+
self.tmp_dir = tempfile.mkdtemp()
|
|
19
|
+
self.db_path = os.path.join(self.tmp_dir, "audit.db")
|
|
20
|
+
|
|
21
|
+
def teardown_method(self):
|
|
22
|
+
import shutil
|
|
23
|
+
shutil.rmtree(self.tmp_dir, ignore_errors=True)
|
|
24
|
+
|
|
25
|
+
def test_creation(self):
|
|
26
|
+
from compliance.audit_db import AuditDB
|
|
27
|
+
db = AuditDB(self.db_path)
|
|
28
|
+
assert db is not None
|
|
29
|
+
|
|
30
|
+
def test_schema_created(self):
|
|
31
|
+
from compliance.audit_db import AuditDB
|
|
32
|
+
db = AuditDB(self.db_path)
|
|
33
|
+
conn = sqlite3.connect(self.db_path)
|
|
34
|
+
tables = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()}
|
|
35
|
+
conn.close()
|
|
36
|
+
assert "audit_events" in tables
|
|
37
|
+
|
|
38
|
+
def test_log_event(self):
|
|
39
|
+
from compliance.audit_db import AuditDB
|
|
40
|
+
db = AuditDB(self.db_path)
|
|
41
|
+
eid = db.log_event(event_type="memory.created", actor="user", resource_id=1, details={"action": "create"})
|
|
42
|
+
assert isinstance(eid, int)
|
|
43
|
+
assert eid > 0
|
|
44
|
+
|
|
45
|
+
def test_hash_chain_first_entry(self):
|
|
46
|
+
"""First entry's prev_hash should be a known genesis value."""
|
|
47
|
+
from compliance.audit_db import AuditDB
|
|
48
|
+
db = AuditDB(self.db_path)
|
|
49
|
+
db.log_event("memory.created", actor="user", resource_id=1)
|
|
50
|
+
conn = sqlite3.connect(self.db_path)
|
|
51
|
+
row = conn.execute("SELECT prev_hash, entry_hash FROM audit_events WHERE id=1").fetchone()
|
|
52
|
+
conn.close()
|
|
53
|
+
assert row[0] == "genesis"
|
|
54
|
+
assert row[1] is not None and len(row[1]) == 64 # SHA-256 hex
|
|
55
|
+
|
|
56
|
+
def test_hash_chain_links(self):
|
|
57
|
+
"""Each entry's prev_hash should equal the previous entry's entry_hash."""
|
|
58
|
+
from compliance.audit_db import AuditDB
|
|
59
|
+
db = AuditDB(self.db_path)
|
|
60
|
+
db.log_event("memory.created", actor="user", resource_id=1)
|
|
61
|
+
db.log_event("memory.recalled", actor="agent_a", resource_id=2)
|
|
62
|
+
db.log_event("memory.deleted", actor="user", resource_id=1)
|
|
63
|
+
conn = sqlite3.connect(self.db_path)
|
|
64
|
+
rows = conn.execute("SELECT id, prev_hash, entry_hash FROM audit_events ORDER BY id").fetchall()
|
|
65
|
+
conn.close()
|
|
66
|
+
assert rows[1][1] == rows[0][2] # Entry 2's prev = Entry 1's hash
|
|
67
|
+
assert rows[2][1] == rows[1][2] # Entry 3's prev = Entry 2's hash
|
|
68
|
+
|
|
69
|
+
def test_verify_chain_valid(self):
|
|
70
|
+
"""verify_chain returns True for untampered chain."""
|
|
71
|
+
from compliance.audit_db import AuditDB
|
|
72
|
+
db = AuditDB(self.db_path)
|
|
73
|
+
db.log_event("memory.created", actor="user", resource_id=1)
|
|
74
|
+
db.log_event("memory.recalled", actor="agent_a", resource_id=1)
|
|
75
|
+
result = db.verify_chain()
|
|
76
|
+
assert result["valid"] is True
|
|
77
|
+
assert result["entries_checked"] == 2
|
|
78
|
+
|
|
79
|
+
def test_verify_chain_detects_tampering(self):
|
|
80
|
+
"""verify_chain returns False if an entry was modified."""
|
|
81
|
+
from compliance.audit_db import AuditDB
|
|
82
|
+
db = AuditDB(self.db_path)
|
|
83
|
+
db.log_event("memory.created", actor="user", resource_id=1)
|
|
84
|
+
db.log_event("memory.recalled", actor="agent_a", resource_id=1)
|
|
85
|
+
# Tamper with the first entry
|
|
86
|
+
conn = sqlite3.connect(self.db_path)
|
|
87
|
+
conn.execute("UPDATE audit_events SET actor='hacker' WHERE id=1")
|
|
88
|
+
conn.commit()
|
|
89
|
+
conn.close()
|
|
90
|
+
result = db.verify_chain()
|
|
91
|
+
assert result["valid"] is False
|
|
92
|
+
|
|
93
|
+
def test_query_by_type(self):
|
|
94
|
+
from compliance.audit_db import AuditDB
|
|
95
|
+
db = AuditDB(self.db_path)
|
|
96
|
+
db.log_event("memory.created", actor="user", resource_id=1)
|
|
97
|
+
db.log_event("memory.recalled", actor="user", resource_id=1)
|
|
98
|
+
db.log_event("memory.created", actor="user", resource_id=2)
|
|
99
|
+
results = db.query_events(event_type="memory.created")
|
|
100
|
+
assert len(results) == 2
|
|
101
|
+
|
|
102
|
+
def test_query_by_actor(self):
|
|
103
|
+
from compliance.audit_db import AuditDB
|
|
104
|
+
db = AuditDB(self.db_path)
|
|
105
|
+
db.log_event("memory.created", actor="user", resource_id=1)
|
|
106
|
+
db.log_event("memory.recalled", actor="agent_a", resource_id=1)
|
|
107
|
+
results = db.query_events(actor="agent_a")
|
|
108
|
+
assert len(results) == 1
|
|
109
|
+
|
|
110
|
+
def test_query_by_time_range(self):
|
|
111
|
+
from compliance.audit_db import AuditDB
|
|
112
|
+
db = AuditDB(self.db_path)
|
|
113
|
+
db.log_event("memory.created", actor="user", resource_id=1)
|
|
114
|
+
results = db.query_events(limit=10)
|
|
115
|
+
assert len(results) >= 1
|
|
116
|
+
assert "created_at" in results[0]
|
|
117
|
+
|
|
118
|
+
def test_empty_chain_is_valid(self):
|
|
119
|
+
from compliance.audit_db import AuditDB
|
|
120
|
+
db = AuditDB(self.db_path)
|
|
121
|
+
result = db.verify_chain()
|
|
122
|
+
assert result["valid"] is True
|
|
123
|
+
assert result["entries_checked"] == 0
|