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.
Files changed (170) hide show
  1. package/CHANGELOG.md +120 -155
  2. package/README.md +115 -89
  3. package/api_server.py +2 -12
  4. package/docs/PATTERN-LEARNING.md +64 -199
  5. package/docs/example_graph_usage.py +4 -6
  6. package/install.sh +59 -0
  7. package/mcp_server.py +83 -7
  8. package/package.json +1 -8
  9. package/scripts/generate-thumbnails.py +3 -5
  10. package/skills/slm-build-graph/SKILL.md +1 -1
  11. package/skills/slm-list-recent/SKILL.md +1 -1
  12. package/skills/slm-recall/SKILL.md +1 -1
  13. package/skills/slm-remember/SKILL.md +1 -1
  14. package/skills/slm-show-patterns/SKILL.md +1 -1
  15. package/skills/slm-status/SKILL.md +1 -1
  16. package/skills/slm-switch-profile/SKILL.md +1 -1
  17. package/src/agent_registry.py +7 -18
  18. package/src/auth_middleware.py +3 -5
  19. package/src/auto_backup.py +3 -7
  20. package/src/behavioral/__init__.py +49 -0
  21. package/src/behavioral/behavioral_listener.py +203 -0
  22. package/src/behavioral/behavioral_patterns.py +275 -0
  23. package/src/behavioral/cross_project_transfer.py +206 -0
  24. package/src/behavioral/outcome_inference.py +194 -0
  25. package/src/behavioral/outcome_tracker.py +193 -0
  26. package/src/behavioral/tests/__init__.py +4 -0
  27. package/src/behavioral/tests/test_behavioral_integration.py +108 -0
  28. package/src/behavioral/tests/test_behavioral_patterns.py +150 -0
  29. package/src/behavioral/tests/test_cross_project_transfer.py +142 -0
  30. package/src/behavioral/tests/test_mcp_behavioral.py +139 -0
  31. package/src/behavioral/tests/test_mcp_report_outcome.py +117 -0
  32. package/src/behavioral/tests/test_outcome_inference.py +107 -0
  33. package/src/behavioral/tests/test_outcome_tracker.py +96 -0
  34. package/src/cache_manager.py +4 -6
  35. package/src/compliance/__init__.py +48 -0
  36. package/src/compliance/abac_engine.py +149 -0
  37. package/src/compliance/abac_middleware.py +116 -0
  38. package/src/compliance/audit_db.py +215 -0
  39. package/src/compliance/audit_logger.py +148 -0
  40. package/src/compliance/retention_manager.py +289 -0
  41. package/src/compliance/retention_scheduler.py +186 -0
  42. package/src/compliance/tests/__init__.py +4 -0
  43. package/src/compliance/tests/test_abac_enforcement.py +95 -0
  44. package/src/compliance/tests/test_abac_engine.py +124 -0
  45. package/src/compliance/tests/test_abac_mcp_integration.py +118 -0
  46. package/src/compliance/tests/test_audit_db.py +123 -0
  47. package/src/compliance/tests/test_audit_logger.py +98 -0
  48. package/src/compliance/tests/test_mcp_audit.py +128 -0
  49. package/src/compliance/tests/test_mcp_retention_policy.py +125 -0
  50. package/src/compliance/tests/test_retention_manager.py +131 -0
  51. package/src/compliance/tests/test_retention_scheduler.py +99 -0
  52. package/src/db_connection_manager.py +2 -12
  53. package/src/embedding_engine.py +61 -669
  54. package/src/embeddings/__init__.py +47 -0
  55. package/src/embeddings/cache.py +70 -0
  56. package/src/embeddings/cli.py +113 -0
  57. package/src/embeddings/constants.py +47 -0
  58. package/src/embeddings/database.py +91 -0
  59. package/src/embeddings/engine.py +247 -0
  60. package/src/embeddings/model_loader.py +145 -0
  61. package/src/event_bus.py +3 -13
  62. package/src/graph/__init__.py +36 -0
  63. package/src/graph/build_helpers.py +74 -0
  64. package/src/graph/cli.py +87 -0
  65. package/src/graph/cluster_builder.py +188 -0
  66. package/src/graph/cluster_summary.py +148 -0
  67. package/src/graph/constants.py +47 -0
  68. package/src/graph/edge_builder.py +162 -0
  69. package/src/graph/entity_extractor.py +95 -0
  70. package/src/graph/graph_core.py +226 -0
  71. package/src/graph/graph_search.py +231 -0
  72. package/src/graph/hierarchical.py +207 -0
  73. package/src/graph/schema.py +99 -0
  74. package/src/graph_engine.py +45 -1451
  75. package/src/hnsw_index.py +3 -7
  76. package/src/hybrid_search.py +36 -683
  77. package/src/learning/__init__.py +27 -12
  78. package/src/learning/adaptive_ranker.py +50 -12
  79. package/src/learning/cross_project_aggregator.py +2 -12
  80. package/src/learning/engagement_tracker.py +2 -12
  81. package/src/learning/feature_extractor.py +175 -43
  82. package/src/learning/feedback_collector.py +7 -12
  83. package/src/learning/learning_db.py +180 -12
  84. package/src/learning/project_context_manager.py +2 -12
  85. package/src/learning/source_quality_scorer.py +2 -12
  86. package/src/learning/synthetic_bootstrap.py +2 -12
  87. package/src/learning/tests/__init__.py +2 -0
  88. package/src/learning/tests/test_adaptive_ranker.py +2 -6
  89. package/src/learning/tests/test_adaptive_ranker_v28.py +60 -0
  90. package/src/learning/tests/test_aggregator.py +2 -6
  91. package/src/learning/tests/test_auto_retrain_v28.py +35 -0
  92. package/src/learning/tests/test_e2e_ranking_v28.py +82 -0
  93. package/src/learning/tests/test_feature_extractor_v28.py +93 -0
  94. package/src/learning/tests/test_feedback_collector.py +2 -6
  95. package/src/learning/tests/test_learning_db.py +2 -6
  96. package/src/learning/tests/test_learning_db_v28.py +110 -0
  97. package/src/learning/tests/test_learning_init_v28.py +48 -0
  98. package/src/learning/tests/test_outcome_signals.py +48 -0
  99. package/src/learning/tests/test_project_context.py +2 -6
  100. package/src/learning/tests/test_schema_migration.py +319 -0
  101. package/src/learning/tests/test_signal_inference.py +11 -13
  102. package/src/learning/tests/test_source_quality.py +2 -6
  103. package/src/learning/tests/test_synthetic_bootstrap.py +3 -7
  104. package/src/learning/tests/test_workflow_miner.py +2 -6
  105. package/src/learning/workflow_pattern_miner.py +2 -12
  106. package/src/lifecycle/__init__.py +54 -0
  107. package/src/lifecycle/bounded_growth.py +239 -0
  108. package/src/lifecycle/compaction_engine.py +226 -0
  109. package/src/lifecycle/lifecycle_engine.py +302 -0
  110. package/src/lifecycle/lifecycle_evaluator.py +225 -0
  111. package/src/lifecycle/lifecycle_scheduler.py +130 -0
  112. package/src/lifecycle/retention_policy.py +285 -0
  113. package/src/lifecycle/tests/__init__.py +4 -0
  114. package/src/lifecycle/tests/test_bounded_growth.py +193 -0
  115. package/src/lifecycle/tests/test_compaction.py +179 -0
  116. package/src/lifecycle/tests/test_lifecycle_engine.py +137 -0
  117. package/src/lifecycle/tests/test_lifecycle_evaluation.py +177 -0
  118. package/src/lifecycle/tests/test_lifecycle_scheduler.py +127 -0
  119. package/src/lifecycle/tests/test_lifecycle_search.py +109 -0
  120. package/src/lifecycle/tests/test_mcp_compact.py +149 -0
  121. package/src/lifecycle/tests/test_mcp_lifecycle_status.py +114 -0
  122. package/src/lifecycle/tests/test_retention_policy.py +162 -0
  123. package/src/mcp_tools_v28.py +280 -0
  124. package/src/memory-profiles.py +2 -12
  125. package/src/memory-reset.py +2 -12
  126. package/src/memory_compression.py +2 -12
  127. package/src/memory_store_v2.py +76 -20
  128. package/src/migrate_v1_to_v2.py +2 -12
  129. package/src/pattern_learner.py +29 -975
  130. package/src/patterns/__init__.py +24 -0
  131. package/src/patterns/analyzers.py +247 -0
  132. package/src/patterns/learner.py +267 -0
  133. package/src/patterns/scoring.py +167 -0
  134. package/src/patterns/store.py +223 -0
  135. package/src/patterns/terminology.py +138 -0
  136. package/src/provenance_tracker.py +4 -14
  137. package/src/query_optimizer.py +4 -6
  138. package/src/rate_limiter.py +2 -6
  139. package/src/search/__init__.py +20 -0
  140. package/src/search/cli.py +77 -0
  141. package/src/search/constants.py +26 -0
  142. package/src/search/engine.py +239 -0
  143. package/src/search/fusion.py +122 -0
  144. package/src/search/index_loader.py +112 -0
  145. package/src/search/methods.py +162 -0
  146. package/src/search_engine_v2.py +4 -6
  147. package/src/setup_validator.py +7 -13
  148. package/src/subscription_manager.py +2 -12
  149. package/src/tree/__init__.py +59 -0
  150. package/src/tree/builder.py +183 -0
  151. package/src/tree/nodes.py +196 -0
  152. package/src/tree/queries.py +252 -0
  153. package/src/tree/schema.py +76 -0
  154. package/src/tree_manager.py +10 -711
  155. package/src/trust/__init__.py +45 -0
  156. package/src/trust/constants.py +66 -0
  157. package/src/trust/queries.py +157 -0
  158. package/src/trust/schema.py +95 -0
  159. package/src/trust/scorer.py +299 -0
  160. package/src/trust/signals.py +95 -0
  161. package/src/trust_scorer.py +39 -697
  162. package/src/webhook_dispatcher.py +2 -12
  163. package/ui/app.js +1 -1
  164. package/ui/js/agents.js +1 -1
  165. package/ui_server.py +2 -14
  166. package/ATTRIBUTION.md +0 -140
  167. package/docs/ARCHITECTURE-V2.5.md +0 -190
  168. package/docs/GRAPH-ENGINE.md +0 -503
  169. package/docs/architecture-diagram.drawio +0 -405
  170. 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
@@ -1,16 +1,14 @@
1
1
  #!/usr/bin/env python3
2
- """
3
- SuperLocalMemory V2 - Cache Manager
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
- Licensed under MIT License (see LICENSE file)
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
+ }