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,107 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 SuperLocalMemory (superlocalmemory.com)
|
|
3
|
+
"""Tests for implicit outcome inference from recall behavior patterns.
|
|
4
|
+
"""
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestOutcomeInference:
|
|
13
|
+
"""Test inference rules for implicit outcome detection."""
|
|
14
|
+
|
|
15
|
+
def test_no_requery_implies_success(self):
|
|
16
|
+
"""No re-query for 10+ min after recall -> success (0.6)."""
|
|
17
|
+
from behavioral.outcome_inference import OutcomeInference
|
|
18
|
+
engine = OutcomeInference()
|
|
19
|
+
now = datetime.now()
|
|
20
|
+
# Record a recall event
|
|
21
|
+
engine.record_recall("query_abc", [1, 2], now - timedelta(minutes=12))
|
|
22
|
+
# Infer outcomes after enough time has passed
|
|
23
|
+
results = engine.infer_outcomes(now)
|
|
24
|
+
assert len(results) >= 1
|
|
25
|
+
result = results[0]
|
|
26
|
+
assert result["outcome"] == "success"
|
|
27
|
+
assert abs(result["confidence"] - 0.6) < 0.01
|
|
28
|
+
|
|
29
|
+
def test_memory_used_high_confirms_success(self):
|
|
30
|
+
"""memory_used(high) within 5 min -> confirmed success (0.8)."""
|
|
31
|
+
from behavioral.outcome_inference import OutcomeInference
|
|
32
|
+
engine = OutcomeInference()
|
|
33
|
+
now = datetime.now()
|
|
34
|
+
engine.record_recall("query_abc", [1], now - timedelta(minutes=3))
|
|
35
|
+
engine.record_usage("query_abc", signal="mcp_used_high", timestamp=now - timedelta(minutes=1))
|
|
36
|
+
results = engine.infer_outcomes(now)
|
|
37
|
+
success_results = [r for r in results if r["outcome"] == "success"]
|
|
38
|
+
assert len(success_results) >= 1
|
|
39
|
+
assert success_results[0]["confidence"] >= 0.8
|
|
40
|
+
|
|
41
|
+
def test_immediate_requery_implies_failure(self):
|
|
42
|
+
"""Immediate re-query with different terms -> failure (0.2)."""
|
|
43
|
+
from behavioral.outcome_inference import OutcomeInference
|
|
44
|
+
engine = OutcomeInference()
|
|
45
|
+
now = datetime.now()
|
|
46
|
+
engine.record_recall("query_abc", [1], now - timedelta(minutes=1))
|
|
47
|
+
engine.record_recall("different_query", [3], now - timedelta(seconds=30))
|
|
48
|
+
results = engine.infer_outcomes(now)
|
|
49
|
+
failure_results = [r for r in results if r["outcome"] == "failure"]
|
|
50
|
+
assert len(failure_results) >= 1
|
|
51
|
+
assert failure_results[0]["confidence"] <= 0.3
|
|
52
|
+
|
|
53
|
+
def test_memory_deleted_implies_failure(self):
|
|
54
|
+
"""Memory deleted within 1 hour -> failure (0.0)."""
|
|
55
|
+
from behavioral.outcome_inference import OutcomeInference
|
|
56
|
+
engine = OutcomeInference()
|
|
57
|
+
now = datetime.now()
|
|
58
|
+
engine.record_recall("query_abc", [1], now - timedelta(minutes=30))
|
|
59
|
+
engine.record_deletion(memory_id=1, timestamp=now - timedelta(minutes=5))
|
|
60
|
+
results = engine.infer_outcomes(now)
|
|
61
|
+
failure_results = [r for r in results if r["outcome"] == "failure" and 1 in r["memory_ids"]]
|
|
62
|
+
assert len(failure_results) >= 1
|
|
63
|
+
assert failure_results[0]["confidence"] <= 0.05
|
|
64
|
+
|
|
65
|
+
def test_rapid_fire_queries_implies_failure(self):
|
|
66
|
+
"""3+ queries in 2 min -> failure (0.1)."""
|
|
67
|
+
from behavioral.outcome_inference import OutcomeInference
|
|
68
|
+
engine = OutcomeInference()
|
|
69
|
+
now = datetime.now()
|
|
70
|
+
engine.record_recall("q1", [1], now - timedelta(seconds=90))
|
|
71
|
+
engine.record_recall("q2", [2], now - timedelta(seconds=60))
|
|
72
|
+
engine.record_recall("q3", [3], now - timedelta(seconds=30))
|
|
73
|
+
results = engine.infer_outcomes(now)
|
|
74
|
+
# At least some should be failure due to rapid-fire pattern
|
|
75
|
+
failure_results = [r for r in results if r["outcome"] == "failure"]
|
|
76
|
+
assert len(failure_results) >= 1
|
|
77
|
+
|
|
78
|
+
def test_cross_tool_access_implies_success(self):
|
|
79
|
+
"""Cross-tool access after recall -> success (0.7)."""
|
|
80
|
+
from behavioral.outcome_inference import OutcomeInference
|
|
81
|
+
engine = OutcomeInference()
|
|
82
|
+
now = datetime.now()
|
|
83
|
+
engine.record_recall("query_abc", [1], now - timedelta(minutes=3))
|
|
84
|
+
engine.record_usage("query_abc", signal="implicit_positive_cross_tool", timestamp=now - timedelta(minutes=1))
|
|
85
|
+
results = engine.infer_outcomes(now)
|
|
86
|
+
success_results = [r for r in results if r["outcome"] == "success"]
|
|
87
|
+
assert len(success_results) >= 1
|
|
88
|
+
assert success_results[0]["confidence"] >= 0.7
|
|
89
|
+
|
|
90
|
+
def test_empty_buffer_returns_empty(self):
|
|
91
|
+
"""No recorded events -> no inferences."""
|
|
92
|
+
from behavioral.outcome_inference import OutcomeInference
|
|
93
|
+
engine = OutcomeInference()
|
|
94
|
+
results = engine.infer_outcomes(datetime.now())
|
|
95
|
+
assert results == []
|
|
96
|
+
|
|
97
|
+
def test_infer_clears_processed_events(self):
|
|
98
|
+
"""After inference, processed events are cleared from buffer."""
|
|
99
|
+
from behavioral.outcome_inference import OutcomeInference
|
|
100
|
+
engine = OutcomeInference()
|
|
101
|
+
now = datetime.now()
|
|
102
|
+
engine.record_recall("q1", [1], now - timedelta(minutes=12))
|
|
103
|
+
results1 = engine.infer_outcomes(now)
|
|
104
|
+
assert len(results1) >= 1
|
|
105
|
+
# Second call should have nothing new
|
|
106
|
+
results2 = engine.infer_outcomes(now)
|
|
107
|
+
assert len(results2) == 0
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 SuperLocalMemory (superlocalmemory.com)
|
|
3
|
+
"""Tests for explicit action outcome recording.
|
|
4
|
+
"""
|
|
5
|
+
import sqlite3
|
|
6
|
+
import tempfile
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TestOutcomeTracker:
|
|
16
|
+
"""Test outcome recording and querying."""
|
|
17
|
+
|
|
18
|
+
def setup_method(self):
|
|
19
|
+
self.tmp_dir = tempfile.mkdtemp()
|
|
20
|
+
self.db_path = os.path.join(self.tmp_dir, "learning.db")
|
|
21
|
+
|
|
22
|
+
def teardown_method(self):
|
|
23
|
+
import shutil
|
|
24
|
+
shutil.rmtree(self.tmp_dir, ignore_errors=True)
|
|
25
|
+
|
|
26
|
+
def test_record_success(self):
|
|
27
|
+
from behavioral.outcome_tracker import OutcomeTracker
|
|
28
|
+
tracker = OutcomeTracker(self.db_path)
|
|
29
|
+
oid = tracker.record_outcome([1, 2], "success", action_type="code_written")
|
|
30
|
+
assert isinstance(oid, int)
|
|
31
|
+
assert oid > 0
|
|
32
|
+
|
|
33
|
+
def test_record_failure(self):
|
|
34
|
+
from behavioral.outcome_tracker import OutcomeTracker
|
|
35
|
+
tracker = OutcomeTracker(self.db_path)
|
|
36
|
+
oid = tracker.record_outcome([3], "failure", context={"error": "timeout"})
|
|
37
|
+
assert oid > 0
|
|
38
|
+
|
|
39
|
+
def test_record_partial(self):
|
|
40
|
+
from behavioral.outcome_tracker import OutcomeTracker
|
|
41
|
+
tracker = OutcomeTracker(self.db_path)
|
|
42
|
+
oid = tracker.record_outcome([1], "partial", action_type="debug_resolved")
|
|
43
|
+
assert oid > 0
|
|
44
|
+
|
|
45
|
+
def test_confidence_for_explicit(self):
|
|
46
|
+
"""Explicit outcomes should have confidence >= 0.8."""
|
|
47
|
+
from behavioral.outcome_tracker import OutcomeTracker
|
|
48
|
+
tracker = OutcomeTracker(self.db_path)
|
|
49
|
+
tracker.record_outcome([1], "success")
|
|
50
|
+
outcomes = tracker.get_outcomes()
|
|
51
|
+
assert outcomes[0]["confidence"] >= 0.8
|
|
52
|
+
|
|
53
|
+
def test_multiple_memory_ids(self):
|
|
54
|
+
from behavioral.outcome_tracker import OutcomeTracker
|
|
55
|
+
tracker = OutcomeTracker(self.db_path)
|
|
56
|
+
tracker.record_outcome([1, 2, 3], "success")
|
|
57
|
+
outcomes = tracker.get_outcomes()
|
|
58
|
+
assert len(outcomes[0]["memory_ids"]) == 3
|
|
59
|
+
|
|
60
|
+
def test_get_outcomes_by_memory(self):
|
|
61
|
+
from behavioral.outcome_tracker import OutcomeTracker
|
|
62
|
+
tracker = OutcomeTracker(self.db_path)
|
|
63
|
+
tracker.record_outcome([1, 2], "success")
|
|
64
|
+
tracker.record_outcome([3], "failure")
|
|
65
|
+
results = tracker.get_outcomes(memory_id=1)
|
|
66
|
+
assert len(results) == 1
|
|
67
|
+
|
|
68
|
+
def test_get_outcomes_by_project(self):
|
|
69
|
+
from behavioral.outcome_tracker import OutcomeTracker
|
|
70
|
+
tracker = OutcomeTracker(self.db_path)
|
|
71
|
+
tracker.record_outcome([1], "success", project="proj_a")
|
|
72
|
+
tracker.record_outcome([2], "failure", project="proj_b")
|
|
73
|
+
results = tracker.get_outcomes(project="proj_a")
|
|
74
|
+
assert len(results) == 1
|
|
75
|
+
|
|
76
|
+
def test_get_success_rate(self):
|
|
77
|
+
from behavioral.outcome_tracker import OutcomeTracker
|
|
78
|
+
tracker = OutcomeTracker(self.db_path)
|
|
79
|
+
tracker.record_outcome([1], "success")
|
|
80
|
+
tracker.record_outcome([1], "success")
|
|
81
|
+
tracker.record_outcome([1], "failure")
|
|
82
|
+
rate = tracker.get_success_rate(1)
|
|
83
|
+
assert abs(rate - 0.667) < 0.01 # 2/3
|
|
84
|
+
|
|
85
|
+
def test_success_rate_no_outcomes(self):
|
|
86
|
+
from behavioral.outcome_tracker import OutcomeTracker
|
|
87
|
+
tracker = OutcomeTracker(self.db_path)
|
|
88
|
+
rate = tracker.get_success_rate(999)
|
|
89
|
+
assert rate == 0.0
|
|
90
|
+
|
|
91
|
+
def test_valid_outcomes_only(self):
|
|
92
|
+
"""Only success, failure, partial are valid outcomes."""
|
|
93
|
+
from behavioral.outcome_tracker import OutcomeTracker
|
|
94
|
+
tracker = OutcomeTracker(self.db_path)
|
|
95
|
+
result = tracker.record_outcome([1], "invalid_outcome")
|
|
96
|
+
assert result is None # Rejected
|
package/src/cache_manager.py
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
# Copyright (c) 2026 SuperLocalMemory (superlocalmemory.com)
|
|
4
|
+
"""SuperLocalMemory V2 - Cache Manager
|
|
4
5
|
|
|
5
|
-
Copyright (c) 2026 Varun Pratap Bhardwaj
|
|
6
6
|
Solution Architect & Original Creator
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
Repository: https://github.com/varun369/SuperLocalMemoryV2
|
|
8
|
+
(see LICENSE file)
|
|
10
9
|
|
|
11
10
|
ATTRIBUTION REQUIRED: This notice must be preserved in all copies.
|
|
12
11
|
"""
|
|
13
|
-
|
|
14
12
|
"""
|
|
15
13
|
Cache Manager - LRU Cache for Search Results
|
|
16
14
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 SuperLocalMemory (superlocalmemory.com)
|
|
3
|
+
"""SLM v2.8 Compliance Engine — ABAC + Audit Trail + Retention.
|
|
4
|
+
|
|
5
|
+
Enterprise-grade access control, tamper-evident audit trail,
|
|
6
|
+
and retention policy management for GDPR/EU AI Act/HIPAA.
|
|
7
|
+
|
|
8
|
+
Graceful degradation: if this module fails to import,
|
|
9
|
+
all agents have full access (v2.7 behavior).
|
|
10
|
+
"""
|
|
11
|
+
import threading
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional, Dict, Any
|
|
14
|
+
|
|
15
|
+
COMPLIANCE_AVAILABLE = False
|
|
16
|
+
_init_error = None
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from .abac_engine import ABACEngine
|
|
20
|
+
from .audit_db import AuditDB
|
|
21
|
+
COMPLIANCE_AVAILABLE = True
|
|
22
|
+
except ImportError as e:
|
|
23
|
+
_init_error = str(e)
|
|
24
|
+
|
|
25
|
+
_abac_engine: Optional["ABACEngine"] = None
|
|
26
|
+
_abac_lock = threading.Lock()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_abac_engine(config_path: Optional[Path] = None) -> Optional["ABACEngine"]:
|
|
30
|
+
"""Get or create the ABAC engine singleton."""
|
|
31
|
+
global _abac_engine
|
|
32
|
+
if not COMPLIANCE_AVAILABLE:
|
|
33
|
+
return None
|
|
34
|
+
with _abac_lock:
|
|
35
|
+
if _abac_engine is None:
|
|
36
|
+
try:
|
|
37
|
+
_abac_engine = ABACEngine(config_path)
|
|
38
|
+
except Exception:
|
|
39
|
+
return None
|
|
40
|
+
return _abac_engine
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_status() -> Dict[str, Any]:
|
|
44
|
+
return {
|
|
45
|
+
"compliance_available": COMPLIANCE_AVAILABLE,
|
|
46
|
+
"init_error": _init_error,
|
|
47
|
+
"abac_active": _abac_engine is not None,
|
|
48
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 SuperLocalMemory (superlocalmemory.com)
|
|
3
|
+
"""Attribute-Based Access Control policy evaluation.
|
|
4
|
+
|
|
5
|
+
Evaluates access requests against JSON-defined policies using
|
|
6
|
+
subject, resource, and action attributes. Deny-first semantics
|
|
7
|
+
ensure any matching deny policy blocks access regardless of
|
|
8
|
+
allow policies. When no policies exist, all access is permitted
|
|
9
|
+
(backward compatible with v2.7 default-allow behavior).
|
|
10
|
+
|
|
11
|
+
Policy format:
|
|
12
|
+
{
|
|
13
|
+
"name": str, # Human-readable policy name
|
|
14
|
+
"effect": str, # "allow" or "deny"
|
|
15
|
+
"subjects": dict, # Attribute constraints on the requester
|
|
16
|
+
"resources": dict, # Attribute constraints on the resource
|
|
17
|
+
"actions": list[str] # Actions this policy applies to
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
Matching rules:
|
|
21
|
+
- "*" matches any value for that attribute
|
|
22
|
+
- Specific values require exact match
|
|
23
|
+
- All attributes in the policy must match for the policy to apply
|
|
24
|
+
"""
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, Dict, List, Optional
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ABACEngine:
|
|
34
|
+
"""Evaluates ABAC policies for memory access control.
|
|
35
|
+
|
|
36
|
+
Deny-first evaluation: if ANY deny policy matches the request,
|
|
37
|
+
access is denied. If no deny matches, access is allowed
|
|
38
|
+
(default-allow preserves v2.7 backward compatibility).
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, config_path: Optional[str] = None) -> None:
|
|
42
|
+
self._config_path = config_path
|
|
43
|
+
self.policies: List[Dict[str, Any]] = []
|
|
44
|
+
if config_path:
|
|
45
|
+
self._load_policies(config_path)
|
|
46
|
+
|
|
47
|
+
def _load_policies(self, path: str) -> None:
|
|
48
|
+
"""Load policies from a JSON file. Graceful on missing/invalid."""
|
|
49
|
+
try:
|
|
50
|
+
raw = Path(path).read_text(encoding="utf-8")
|
|
51
|
+
data = json.loads(raw)
|
|
52
|
+
if isinstance(data, list):
|
|
53
|
+
self.policies = data
|
|
54
|
+
logger.info("Loaded %d ABAC policies from %s", len(data), path)
|
|
55
|
+
else:
|
|
56
|
+
logger.warning("ABAC policy file is not a list: %s", path)
|
|
57
|
+
except FileNotFoundError:
|
|
58
|
+
logger.debug("No ABAC policy file at %s — default allow", path)
|
|
59
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
60
|
+
logger.warning("Failed to parse ABAC policies: %s", exc)
|
|
61
|
+
|
|
62
|
+
def evaluate(
|
|
63
|
+
self,
|
|
64
|
+
subject: Dict[str, Any],
|
|
65
|
+
resource: Dict[str, Any],
|
|
66
|
+
action: str,
|
|
67
|
+
) -> Dict[str, Any]:
|
|
68
|
+
"""Evaluate an access request against loaded policies.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
subject: Attributes of the requester (e.g. agent_id).
|
|
72
|
+
resource: Attributes of the target resource.
|
|
73
|
+
action: The action being requested (read/write/delete).
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Dict with keys: allowed (bool), reason (str),
|
|
77
|
+
and policy_name (str) when a specific policy decided.
|
|
78
|
+
"""
|
|
79
|
+
if not self.policies:
|
|
80
|
+
return {"allowed": True, "reason": "no_policies_loaded"}
|
|
81
|
+
|
|
82
|
+
# Phase 1: check all deny policies first
|
|
83
|
+
for policy in self.policies:
|
|
84
|
+
if policy.get("effect") != "deny":
|
|
85
|
+
continue
|
|
86
|
+
if self._matches(policy, subject, resource, action):
|
|
87
|
+
return {
|
|
88
|
+
"allowed": False,
|
|
89
|
+
"reason": "denied_by_policy",
|
|
90
|
+
"policy_name": policy.get("name", "unnamed"),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
# Phase 2: check allow policies
|
|
94
|
+
for policy in self.policies:
|
|
95
|
+
if policy.get("effect") != "allow":
|
|
96
|
+
continue
|
|
97
|
+
if self._matches(policy, subject, resource, action):
|
|
98
|
+
return {
|
|
99
|
+
"allowed": True,
|
|
100
|
+
"reason": "allowed_by_policy",
|
|
101
|
+
"policy_name": policy.get("name", "unnamed"),
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# Phase 3: no matching policy — default allow (backward compat)
|
|
105
|
+
return {"allowed": True, "reason": "no_matching_policy"}
|
|
106
|
+
|
|
107
|
+
# ------------------------------------------------------------------
|
|
108
|
+
# Internal matching helpers
|
|
109
|
+
# ------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
def _matches(
|
|
112
|
+
self,
|
|
113
|
+
policy: Dict[str, Any],
|
|
114
|
+
subject: Dict[str, Any],
|
|
115
|
+
resource: Dict[str, Any],
|
|
116
|
+
action: str,
|
|
117
|
+
) -> bool:
|
|
118
|
+
"""Return True if policy matches the request."""
|
|
119
|
+
if not self._action_matches(policy.get("actions", []), action):
|
|
120
|
+
return False
|
|
121
|
+
if not self._attrs_match(policy.get("subjects", {}), subject):
|
|
122
|
+
return False
|
|
123
|
+
if not self._attrs_match(policy.get("resources", {}), resource):
|
|
124
|
+
return False
|
|
125
|
+
return True
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def _action_matches(policy_actions: List[str], action: str) -> bool:
|
|
129
|
+
"""Check if the requested action is in the policy's action list."""
|
|
130
|
+
if "*" in policy_actions:
|
|
131
|
+
return True
|
|
132
|
+
return action in policy_actions
|
|
133
|
+
|
|
134
|
+
@staticmethod
|
|
135
|
+
def _attrs_match(
|
|
136
|
+
policy_attrs: Dict[str, Any],
|
|
137
|
+
request_attrs: Dict[str, Any],
|
|
138
|
+
) -> bool:
|
|
139
|
+
"""Check if all policy attribute constraints are satisfied.
|
|
140
|
+
|
|
141
|
+
Every key in policy_attrs must either be "*" (match anything)
|
|
142
|
+
or exactly equal the corresponding value in request_attrs.
|
|
143
|
+
"""
|
|
144
|
+
for key, expected in policy_attrs.items():
|
|
145
|
+
if expected == "*":
|
|
146
|
+
continue
|
|
147
|
+
if request_attrs.get(key) != expected:
|
|
148
|
+
return False
|
|
149
|
+
return True
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 SuperLocalMemory (superlocalmemory.com)
|
|
3
|
+
"""ABAC middleware for MCP tool integration.
|
|
4
|
+
|
|
5
|
+
Provides a simple interface for MCP tools to check access before
|
|
6
|
+
executing memory operations. Delegates to ABACEngine for policy
|
|
7
|
+
evaluation.
|
|
8
|
+
"""
|
|
9
|
+
import threading
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Dict, Optional
|
|
12
|
+
|
|
13
|
+
from .abac_engine import ABACEngine
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ABACMiddleware:
|
|
17
|
+
"""Thin middleware between MCP tools and ABAC policy engine.
|
|
18
|
+
|
|
19
|
+
Usage from MCP tools:
|
|
20
|
+
mw = ABACMiddleware(db_path)
|
|
21
|
+
result = mw.check_access(agent_id="agent_1", action="read",
|
|
22
|
+
resource={"access_level": "private"})
|
|
23
|
+
if not result["allowed"]:
|
|
24
|
+
return error_response(result["reason"])
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
db_path: Optional[str] = None,
|
|
30
|
+
policy_path: Optional[str] = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
if db_path is None:
|
|
33
|
+
db_path = str(Path.home() / ".claude-memory" / "memory.db")
|
|
34
|
+
self._db_path = str(db_path)
|
|
35
|
+
|
|
36
|
+
if policy_path is None:
|
|
37
|
+
policy_path = str(
|
|
38
|
+
Path(self._db_path).parent / "abac_policies.json"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
self._lock = threading.Lock()
|
|
42
|
+
self.denied_count = 0
|
|
43
|
+
self.allowed_count = 0
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
self._engine = ABACEngine(config_path=policy_path)
|
|
47
|
+
except Exception:
|
|
48
|
+
self._engine = None
|
|
49
|
+
|
|
50
|
+
def check_access(
|
|
51
|
+
self,
|
|
52
|
+
agent_id: str,
|
|
53
|
+
action: str,
|
|
54
|
+
resource: Optional[Dict[str, Any]] = None,
|
|
55
|
+
) -> Dict[str, Any]:
|
|
56
|
+
"""Check if an agent has access to perform an action.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
agent_id: Identifier for the requesting agent.
|
|
60
|
+
action: The action being performed (read, write, delete).
|
|
61
|
+
resource: Resource attributes (access_level, project, tags).
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Dict with ``allowed`` (bool), ``reason`` (str), and
|
|
65
|
+
optionally ``policy_name``.
|
|
66
|
+
"""
|
|
67
|
+
if self._engine is None:
|
|
68
|
+
return {
|
|
69
|
+
"allowed": True,
|
|
70
|
+
"reason": "ABAC engine unavailable — default allow",
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
subject = {"agent_id": agent_id}
|
|
74
|
+
result = self._engine.evaluate(
|
|
75
|
+
subject=subject,
|
|
76
|
+
resource=resource or {},
|
|
77
|
+
action=action,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
with self._lock:
|
|
81
|
+
if result["allowed"]:
|
|
82
|
+
self.allowed_count += 1
|
|
83
|
+
else:
|
|
84
|
+
self.denied_count += 1
|
|
85
|
+
|
|
86
|
+
return result
|
|
87
|
+
|
|
88
|
+
def build_agent_context(
|
|
89
|
+
self,
|
|
90
|
+
agent_id: str = "user",
|
|
91
|
+
protocol: str = "mcp",
|
|
92
|
+
) -> Dict[str, Any]:
|
|
93
|
+
"""Build an agent context dict for passing to MemoryStoreV2.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
agent_id: Agent identifier.
|
|
97
|
+
protocol: Access protocol (mcp, cli, dashboard).
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Dict suitable for ``MemoryStoreV2.search(agent_context=...)``.
|
|
101
|
+
"""
|
|
102
|
+
return {
|
|
103
|
+
"agent_id": agent_id,
|
|
104
|
+
"protocol": protocol,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
def get_status(self) -> Dict[str, Any]:
|
|
108
|
+
"""Return middleware status."""
|
|
109
|
+
return {
|
|
110
|
+
"engine_available": self._engine is not None,
|
|
111
|
+
"policies_loaded": (
|
|
112
|
+
len(self._engine.policies) if self._engine else 0
|
|
113
|
+
),
|
|
114
|
+
"allowed_count": self.allowed_count,
|
|
115
|
+
"denied_count": self.denied_count,
|
|
116
|
+
}
|