superlocalmemory 3.8.13 → 4.0.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/ATTRIBUTION.md +4 -4
- package/CHANGELOG.md +113 -121
- package/README.md +65 -63
- package/docs/pi-dev-integration.md +1 -1
- package/package.json +6 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-governance/SKILL.md +248 -0
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-loop/SKILL.md +99 -0
- package/plugin-src/skills/slm-mesh/SKILL.md +282 -0
- package/plugin-src/skills/slm-profile/SKILL.md +148 -0
- package/plugin-src/skills/slm-recall/SKILL.md +1 -1
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-scope/SKILL.md +176 -0
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +11 -4
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +125 -11
- package/src/superlocalmemory/cli/daemon.py +5 -1
- package/src/superlocalmemory/cli/main.py +35 -2
- package/src/superlocalmemory/cli/ops_cmd.py +281 -0
- package/src/superlocalmemory/cli/setup_wizard.py +1 -1
- package/src/superlocalmemory/compliance/audit.py +65 -0
- package/src/superlocalmemory/compliance/eu_ai_act.py +27 -57
- package/src/superlocalmemory/compliance/gdpr.py +416 -20
- package/src/superlocalmemory/compliance/retention.py +74 -22
- package/src/superlocalmemory/compliance/scheduler.py +78 -9
- package/src/superlocalmemory/core/actor_context.py +166 -0
- package/src/superlocalmemory/core/admission.py +549 -0
- package/src/superlocalmemory/core/backend_orchestrator.py +23 -10
- package/src/superlocalmemory/core/config.py +202 -24
- package/src/superlocalmemory/core/consolidation_engine.py +13 -13
- package/src/superlocalmemory/core/context_cache.py +28 -0
- package/src/superlocalmemory/core/embeddings.py +64 -2
- package/src/superlocalmemory/core/engine.py +7 -2
- package/src/superlocalmemory/core/engine_ingestion.py +65 -3
- package/src/superlocalmemory/core/engine_wiring.py +36 -9
- package/src/superlocalmemory/core/ingest_policy.py +38 -0
- package/src/superlocalmemory/core/maintenance.py +255 -0
- package/src/superlocalmemory/core/modes.py +40 -13
- package/src/superlocalmemory/core/mutations.py +437 -44
- package/src/superlocalmemory/core/operation_policy.py +92 -0
- package/src/superlocalmemory/core/operation_policy_registry.py +542 -0
- package/src/superlocalmemory/core/operation_request.py +127 -0
- package/src/superlocalmemory/core/ops_remediation.py +542 -0
- package/src/superlocalmemory/core/recall_pipeline.py +7 -0
- package/src/superlocalmemory/core/remember_runtime.py +202 -4
- package/src/superlocalmemory/core/remote_mode.py +20 -5
- package/src/superlocalmemory/core/store_pipeline.py +150 -0
- package/src/superlocalmemory/core/topic_signature.py +19 -4
- package/src/superlocalmemory/core/transactions/__init__.py +78 -0
- package/src/superlocalmemory/core/transactions/concrete_owners.py +597 -0
- package/src/superlocalmemory/core/transactions/erasure.py +825 -0
- package/src/superlocalmemory/core/transactions/manifest.py +255 -0
- package/src/superlocalmemory/core/transactions/manifest_key.py +155 -0
- package/src/superlocalmemory/core/transactions/obligations.py +272 -0
- package/src/superlocalmemory/core/transactions/owners.py +114 -0
- package/src/superlocalmemory/core/transactions/reconciler.py +285 -0
- package/src/superlocalmemory/core/transactions/service.py +330 -0
- package/src/superlocalmemory/core/worker_pool.py +33 -5
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +70 -28
- package/src/superlocalmemory/encoding/emotional.py +75 -14
- package/src/superlocalmemory/encoding/scene_builder.py +115 -13
- package/src/superlocalmemory/encoding/temporal_parser.py +4 -0
- package/src/superlocalmemory/evolution/blind_verifier.py +11 -4
- package/src/superlocalmemory/evolution/evolution_store.py +244 -4
- package/src/superlocalmemory/evolution/llm_dispatch.py +40 -0
- package/src/superlocalmemory/evolution/model_selection.py +18 -3
- package/src/superlocalmemory/evolution/mutation_generator.py +3 -0
- package/src/superlocalmemory/evolution/skill_activator.py +270 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +281 -59
- package/src/superlocalmemory/evolution/types.py +30 -8
- package/src/superlocalmemory/graph/cozo_backend.py +17 -9
- package/src/superlocalmemory/hooks/auto_invoker.py +2 -1
- package/src/superlocalmemory/hooks/auto_recall.py +64 -30
- package/src/superlocalmemory/hooks/codex_assets.py +14 -1
- package/src/superlocalmemory/infra/backup.py +434 -7
- package/src/superlocalmemory/infra/process_reaper.py +18 -0
- package/src/superlocalmemory/infra/self_heal.py +401 -0
- package/src/superlocalmemory/learning/feedback.py +52 -9
- package/src/superlocalmemory/loops/engine.py +10 -0
- package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
- package/src/superlocalmemory/mcp/http_transport.py +30 -331
- package/src/superlocalmemory/mcp/profiles.py +5 -0
- package/src/superlocalmemory/mcp/resources.py +8 -0
- package/src/superlocalmemory/mcp/server.py +51 -4
- package/src/superlocalmemory/mcp/shared.py +19 -0
- package/src/superlocalmemory/mcp/tools_active.py +25 -4
- package/src/superlocalmemory/mcp/tools_code_graph.py +26 -18
- package/src/superlocalmemory/mcp/tools_context.py +50 -8
- package/src/superlocalmemory/mcp/tools_core.py +69 -21
- package/src/superlocalmemory/mcp/tools_evolution.py +9 -2
- package/src/superlocalmemory/mcp/tools_learning.py +21 -10
- package/src/superlocalmemory/mcp/tools_loops.py +29 -18
- package/src/superlocalmemory/mcp/tools_mesh.py +8 -0
- package/src/superlocalmemory/mcp/tools_ops.py +115 -0
- package/src/superlocalmemory/mcp/tools_optimize.py +4 -0
- package/src/superlocalmemory/mcp/tools_v28.py +10 -3
- package/src/superlocalmemory/mcp/tools_v3.py +34 -14
- package/src/superlocalmemory/mcp/tools_v33.py +18 -33
- package/src/superlocalmemory/mesh/broker.py +124 -46
- package/src/superlocalmemory/mesh/broker_security.py +470 -0
- package/src/superlocalmemory/mesh/discovery.py +365 -0
- package/src/superlocalmemory/mesh/lock_protocol.py +313 -0
- package/src/superlocalmemory/mesh/node_identity.py +97 -0
- package/src/superlocalmemory/mesh/outbox_remote.py +429 -0
- package/src/superlocalmemory/mesh/remote_sync.py +511 -28
- package/src/superlocalmemory/mesh/state_sync.py +286 -0
- package/src/superlocalmemory/optimize/config/store.py +45 -0
- package/src/superlocalmemory/parameterization/cross_project.py +12 -0
- package/src/superlocalmemory/parameterization/prompt_injector.py +13 -11
- package/src/superlocalmemory/parameterization/prompt_lifecycle.py +8 -2
- package/src/superlocalmemory/parameterization/workflow_miner.py +17 -0
- package/src/superlocalmemory/retrieval/ann_index.py +5 -0
- package/src/superlocalmemory/retrieval/bm25_channel.py +49 -2
- package/src/superlocalmemory/retrieval/engine.py +19 -4
- package/src/superlocalmemory/retrieval/fusion.py +4 -1
- package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -3
- package/src/superlocalmemory/retrieval/remote_reranker.py +47 -22
- package/src/superlocalmemory/retrieval/reranker.py +32 -1
- package/src/superlocalmemory/retrieval/temporal_channel.py +16 -3
- package/src/superlocalmemory/retrieval/temporal_utils.py +107 -0
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +155 -42
- package/src/superlocalmemory/retrieval/vector_store.py +214 -8
- package/src/superlocalmemory/server/api.py +5 -5
- package/src/superlocalmemory/server/egress_policy.py +258 -0
- package/src/superlocalmemory/server/rbac_enforce.py +32 -0
- package/src/superlocalmemory/server/route_mutations.py +20 -0
- package/src/superlocalmemory/server/routes/compliance.py +153 -7
- package/src/superlocalmemory/server/routes/data_io.py +43 -2
- package/src/superlocalmemory/server/routes/events.py +15 -0
- package/src/superlocalmemory/server/routes/memories.py +56 -3
- package/src/superlocalmemory/server/routes/mesh.py +82 -1
- package/src/superlocalmemory/server/routes/mesh_lock.py +54 -0
- package/src/superlocalmemory/server/routes/mesh_state.py +63 -0
- package/src/superlocalmemory/server/routes/v3_api.py +50 -27
- package/src/superlocalmemory/server/routes/ws.py +86 -0
- package/src/superlocalmemory/server/ui.py +6 -6
- package/src/superlocalmemory/server/unified_daemon.py +942 -119
- package/src/superlocalmemory/storage/_migration_internals.py +568 -0
- package/src/superlocalmemory/storage/_schema_version.py +110 -0
- package/src/superlocalmemory/storage/database.py +329 -24
- package/src/superlocalmemory/storage/embedding_migrator.py +246 -51
- package/src/superlocalmemory/storage/erasure_fence.py +45 -0
- package/src/superlocalmemory/storage/generation_fence.py +63 -0
- package/src/superlocalmemory/storage/migration_runner.py +140 -417
- package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +40 -0
- package/src/superlocalmemory/storage/migrations/M033_projection_transactions.py +148 -0
- package/src/superlocalmemory/storage/migrations/M034_obligation_integrity.py +58 -0
- package/src/superlocalmemory/storage/migrations/M035_erasure_receipts.py +113 -0
- package/src/superlocalmemory/storage/migrations/M036_vector_row_map.py +107 -0
- package/src/superlocalmemory/storage/migrations/M037_manifest_hmac_version.py +162 -0
- package/src/superlocalmemory/storage/migrations/{M033_learning_feedback_channel.py → M038_learning_feedback_channel.py} +3 -3
- package/src/superlocalmemory/storage/migrations/M039_scene_fact_members.py +137 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +4 -2
- package/src/superlocalmemory/storage/schema.py +67 -0
- package/src/superlocalmemory/storage/write_coordinator.py +125 -0
- package/src/superlocalmemory/trust/scorer.py +28 -4
- package/src/superlocalmemory/ui/index.html +14 -3
- package/src/superlocalmemory/ui/js/auto-settings.js +12 -1
- package/src/superlocalmemory/ui/js/brain.js +6 -4
- package/src/superlocalmemory/ui/js/compliance.js +66 -12
- package/src/superlocalmemory/ui/js/dashboard.js +13 -3
- package/src/superlocalmemory/ui/js/feedback.js +8 -2
- package/src/superlocalmemory/ui/js/lifecycle.js +7 -1
- package/src/superlocalmemory/ui/js/modal.js +272 -5
- package/src/superlocalmemory/ui/js/od-backup.js +9 -2
- package/src/superlocalmemory/ui/js/od-compliance-ext.js +301 -0
- package/src/superlocalmemory/ui/js/od-operations.js +154 -23
- package/src/superlocalmemory/ui/js/od-ops-health.js +417 -0
- package/src/superlocalmemory/ui/js/od-optimize.js +35 -21
- package/src/superlocalmemory/ui/js/od-team.js +9 -2
- package/src/superlocalmemory/ui/js/optimize.js +13 -16
- package/src/superlocalmemory/ui/js/profiles.js +7 -3
- package/src/superlocalmemory/ui/js/settings.js +7 -1
- package/src/superlocalmemory/vector/lancedb_backend.py +19 -9
- package/src/superlocalmemory/attribution/mathematical_dna.py +0 -235
- package/src/superlocalmemory/cli/post_install.py +0 -114
- package/src/superlocalmemory/core/clock_monitor.py +0 -45
- package/src/superlocalmemory/core/db_pool.py +0 -80
- package/src/superlocalmemory/core/error_catalog.py +0 -113
- package/src/superlocalmemory/core/loop_watchdog.py +0 -56
- package/src/superlocalmemory/core/priority_queue.py +0 -61
- package/src/superlocalmemory/core/pruning_engine.py +0 -216
- package/src/superlocalmemory/core/queue_dispatcher.py +0 -73
- package/src/superlocalmemory/core/slmignore.py +0 -125
- package/src/superlocalmemory/infra/heartbeat_monitor.py +0 -140
- package/src/superlocalmemory/infra/webhook_dispatcher.py +0 -247
- package/src/superlocalmemory/learning/quantization_scheduler.py +0 -320
- package/src/superlocalmemory/storage/access_control.py +0 -182
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
"""Phase 1 admission gateway — resolve_actor + admit + @admits decorator.
|
|
5
|
+
|
|
6
|
+
This module is the single shared admission API for MCP, CLI, and HTTP transports.
|
|
7
|
+
HTTP already uses the registry directly (unified_daemon.py:3740); this module
|
|
8
|
+
wires the same evaluation to MCP tools and CLI commands.
|
|
9
|
+
|
|
10
|
+
INVARIANT: resolve_actor() derives ActorContext from server-side facts only.
|
|
11
|
+
Never call it with data from the MCP/CLI request body.
|
|
12
|
+
|
|
13
|
+
Key design choices
|
|
14
|
+
------------------
|
|
15
|
+
- Personal/single-user mode → OWNER (frictionless). Zero new friction.
|
|
16
|
+
- Enterprise mode + no principal → ANONYMOUS → denied for mutations.
|
|
17
|
+
- admit() raises AdmissionDenied on deny so the caller can return a clean error.
|
|
18
|
+
- @admits(kind) is a thin async decorator for MCP tools.
|
|
19
|
+
- coverage_self_check() is called at daemon startup.
|
|
20
|
+
- Fail-closed: config.toml present but unreadable → enterprise (not personal).
|
|
21
|
+
|
|
22
|
+
Part of SuperLocalMemory V4 | Phase 1: Admission Gateway
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import functools
|
|
28
|
+
import logging
|
|
29
|
+
from typing import TYPE_CHECKING, FrozenSet
|
|
30
|
+
|
|
31
|
+
from superlocalmemory.core.actor_context import ActorContext, ActorRole, Transport
|
|
32
|
+
from superlocalmemory.core.operation_policy_registry import (
|
|
33
|
+
PolicyDecision,
|
|
34
|
+
_DEFAULT_REGISTRY,
|
|
35
|
+
)
|
|
36
|
+
from superlocalmemory.core.operation_request import OperationKind
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from superlocalmemory.core.config import DeploymentConfig
|
|
40
|
+
from superlocalmemory.core.operation_policy_registry import OperationPolicyRegistry
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
_COMPANY_MODES: frozenset[str] = frozenset({
|
|
45
|
+
"company", "remote", "enterprise", "multi-user", "multi_user",
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _tool_read_only_hint(tool: object) -> object:
|
|
50
|
+
"""Return the tool's read-only annotation under mcp 1.x or 2.x naming.
|
|
51
|
+
|
|
52
|
+
MCP wire protocol uses camelCase ``readOnlyHint``. mcp==2.0.0's
|
|
53
|
+
``ToolAnnotations`` pydantic model stores the field as snake_case
|
|
54
|
+
``read_only_hint`` (alias ``readOnlyHint``) — attribute access by alias
|
|
55
|
+
is not available, so a camelCase-only getattr always returns None and
|
|
56
|
+
every annotated read tool is misclassified as a mutator.
|
|
57
|
+
"""
|
|
58
|
+
ann = getattr(tool, "annotations", None)
|
|
59
|
+
if ann is None:
|
|
60
|
+
return None
|
|
61
|
+
val = getattr(ann, "readOnlyHint", None)
|
|
62
|
+
if val is not None:
|
|
63
|
+
return val
|
|
64
|
+
return getattr(ann, "read_only_hint", None)
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# Tool inventory tracking (populated at decoration time by @admits)
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
# Auto-populated as each @admits decorator is applied. Checked at startup.
|
|
71
|
+
_GATED_MCP_TOOLS: set[str] = set()
|
|
72
|
+
|
|
73
|
+
# Minimal set of MCP mutating tools that MUST be decorated with @admits.
|
|
74
|
+
# Extend this set when new mutating tools are added (Tranche B extends it).
|
|
75
|
+
# coverage_self_check verifies _REQUIRED_MCP_GATES ⊆ _GATED_MCP_TOOLS.
|
|
76
|
+
_REQUIRED_MCP_GATES: frozenset[str] = frozenset({
|
|
77
|
+
# Core mutations (tools_core.py)
|
|
78
|
+
"remember",
|
|
79
|
+
"delete_memory",
|
|
80
|
+
"update_memory",
|
|
81
|
+
"correct_pattern",
|
|
82
|
+
"switch_profile",
|
|
83
|
+
"build_graph",
|
|
84
|
+
# Forgetting / consolidation (tools_v33.py)
|
|
85
|
+
"forget",
|
|
86
|
+
"consolidate_cognitive",
|
|
87
|
+
"quantize",
|
|
88
|
+
# Mode mutations (tools_v3.py)
|
|
89
|
+
"set_mode",
|
|
90
|
+
# Mesh mutations (tools_mesh.py)
|
|
91
|
+
"mesh_send",
|
|
92
|
+
"mesh_lock",
|
|
93
|
+
"mesh_state",
|
|
94
|
+
"mesh_summary",
|
|
95
|
+
# Evolution (tools_evolution.py)
|
|
96
|
+
"evolve_skill",
|
|
97
|
+
# Learning / feedback (tools_learning.py, tools_v28.py, tools_active.py)
|
|
98
|
+
"reinforce_assertion",
|
|
99
|
+
"contradict_assertion",
|
|
100
|
+
"report_outcome",
|
|
101
|
+
"report_feedback",
|
|
102
|
+
"observe",
|
|
103
|
+
"close_session",
|
|
104
|
+
# Optimize / cache (tools_optimize.py)
|
|
105
|
+
"slm_cache_set",
|
|
106
|
+
"slm_compress",
|
|
107
|
+
# Code graph (tools_code_graph.py)
|
|
108
|
+
"update_code_graph",
|
|
109
|
+
# Scoped reads (Tranche C — tools_core.py)
|
|
110
|
+
"recall",
|
|
111
|
+
"search",
|
|
112
|
+
# Tranche E — remaining MCP mutators
|
|
113
|
+
"slm_loop_run",
|
|
114
|
+
"set_retention_policy",
|
|
115
|
+
"compact_memories",
|
|
116
|
+
"log_tool_event",
|
|
117
|
+
"core_memory",
|
|
118
|
+
"run_maintenance",
|
|
119
|
+
"reap_processes",
|
|
120
|
+
"build_code_graph",
|
|
121
|
+
"apply_refactor",
|
|
122
|
+
"link_memory_to_code",
|
|
123
|
+
# Tranche G — mesh_inbox marks messages as read (POST to mesh broker)
|
|
124
|
+
"mesh_inbox",
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
# Exception
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
class AdmissionDenied(Exception):
|
|
133
|
+
"""Raised by admit() when the policy evaluation returns allowed=False.
|
|
134
|
+
|
|
135
|
+
Callers map this to the appropriate transport error:
|
|
136
|
+
- MCP → {"success": False, "error": "not_authorized", "reason": ...}
|
|
137
|
+
- CLI → sys.exit(1) + message
|
|
138
|
+
- HTTP → already handled via PermissionError→403 in unified_daemon.py
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
def __init__(self, decision: PolicyDecision) -> None:
|
|
142
|
+
super().__init__(decision.reason)
|
|
143
|
+
self.decision: PolicyDecision = decision
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
# Deployment config resolution (fail-closed on present-but-unreadable)
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
def _resolve_deployment() -> "DeploymentConfig":
|
|
151
|
+
"""Load DeploymentConfig. Fail-closed when config.toml is present but unreadable.
|
|
152
|
+
|
|
153
|
+
Distinction:
|
|
154
|
+
- config.toml absent → legitimate fresh personal install → PERSONAL (frictionless)
|
|
155
|
+
- config.toml present + unreadable/corrupt → unknown enterprise state → ENTERPRISE
|
|
156
|
+
- config.toml present + readable → use canonical loader result
|
|
157
|
+
"""
|
|
158
|
+
from superlocalmemory.core.config import DEPLOYMENT_ENTERPRISE, DEPLOYMENT_PERSONAL
|
|
159
|
+
|
|
160
|
+
# Step 1: resolve the expected config path (same root as load_deployment_config).
|
|
161
|
+
try:
|
|
162
|
+
from superlocalmemory.infra.data_root import state_path
|
|
163
|
+
config_path = state_path("config.toml")
|
|
164
|
+
config_exists = config_path.exists()
|
|
165
|
+
except Exception as exc:
|
|
166
|
+
logger.debug("admission: cannot resolve config path, personal default: %s", exc)
|
|
167
|
+
return DEPLOYMENT_PERSONAL
|
|
168
|
+
|
|
169
|
+
# Step 2: absent → personal (fresh install, no config ever written).
|
|
170
|
+
if not config_exists:
|
|
171
|
+
return DEPLOYMENT_PERSONAL
|
|
172
|
+
|
|
173
|
+
# Step 3: present → probe raw to detect corrupt/unreadable before delegating.
|
|
174
|
+
try:
|
|
175
|
+
import tomllib
|
|
176
|
+
raw = config_path.read_text(encoding="utf-8")
|
|
177
|
+
parsed_toml = tomllib.loads(raw) # raises on corrupt TOML
|
|
178
|
+
except Exception as exc:
|
|
179
|
+
# File exists but cannot be parsed → fail-closed (treat as enterprise).
|
|
180
|
+
logger.warning(
|
|
181
|
+
"admission: config.toml present but unreadable — fail-closed "
|
|
182
|
+
"(treating as enterprise). Cause: %s", exc,
|
|
183
|
+
)
|
|
184
|
+
return DEPLOYMENT_ENTERPRISE
|
|
185
|
+
|
|
186
|
+
# Step 4: readable → delegate to canonical loader (handles mode/fields).
|
|
187
|
+
try:
|
|
188
|
+
from superlocalmemory.core.config import load_deployment_config
|
|
189
|
+
result = load_deployment_config(config_toml_path=config_path)
|
|
190
|
+
except Exception as exc:
|
|
191
|
+
logger.debug("admission: load_deployment_config raised (unexpected): %s", exc)
|
|
192
|
+
return DEPLOYMENT_PERSONAL
|
|
193
|
+
|
|
194
|
+
# D1 fail-closed: [deployment] section present but mode is unrecognized or
|
|
195
|
+
# absent means someone tried to configure enterprise and a typo/omission
|
|
196
|
+
# should not silently grant personal (OWNER) access on an enterprise box.
|
|
197
|
+
# Explicit mode="personal" is a deliberate choice — honour it.
|
|
198
|
+
# Explicit mode="enterprise" → canonical loader already returned ENTERPRISE.
|
|
199
|
+
_KNOWN_MODES = ("personal", "enterprise")
|
|
200
|
+
dep_section = parsed_toml.get("deployment", {})
|
|
201
|
+
declared_mode = str(dep_section.get("mode", "")).strip().lower()
|
|
202
|
+
if "deployment" in parsed_toml and declared_mode not in _KNOWN_MODES:
|
|
203
|
+
logger.warning(
|
|
204
|
+
"admission: config.toml has [deployment] section but mode %r is "
|
|
205
|
+
"unrecognized/absent — fail-closed → ENTERPRISE.", declared_mode or "<missing>",
|
|
206
|
+
)
|
|
207
|
+
return DEPLOYMENT_ENTERPRISE
|
|
208
|
+
return result
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ---------------------------------------------------------------------------
|
|
212
|
+
# resolve_actor
|
|
213
|
+
# ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
def resolve_actor(
|
|
216
|
+
transport: Transport,
|
|
217
|
+
*,
|
|
218
|
+
profile: str = "",
|
|
219
|
+
principal: str = "",
|
|
220
|
+
session: str = "",
|
|
221
|
+
tier: str = "personal",
|
|
222
|
+
mode: str = "personal",
|
|
223
|
+
client_host: str = "",
|
|
224
|
+
roles: FrozenSet[ActorRole] | None = None,
|
|
225
|
+
) -> ActorContext:
|
|
226
|
+
"""Build a server-derived ActorContext for MCP or CLI transport.
|
|
227
|
+
|
|
228
|
+
Rules
|
|
229
|
+
-----
|
|
230
|
+
- Personal tier (default) → OWNER, no authentication required.
|
|
231
|
+
This preserves the existing single-user UX with zero new friction.
|
|
232
|
+
- Enterprise tier + no principal → ANONYMOUS.
|
|
233
|
+
Mutations will be denied (authentication_required) by admit().
|
|
234
|
+
- Enterprise tier + principal → use supplied roles (default: MEMBER).
|
|
235
|
+
|
|
236
|
+
Parameters
|
|
237
|
+
----------
|
|
238
|
+
transport : MCP, CLI, INTERNAL, etc.
|
|
239
|
+
profile : Active profile id (metadata only).
|
|
240
|
+
principal : Authenticated principal id (from session store, never from
|
|
241
|
+
request body). Empty string → anonymous.
|
|
242
|
+
session : Session token (only the first 16 hex chars are stored).
|
|
243
|
+
tier : Deployment tier: "personal" or "enterprise".
|
|
244
|
+
mode : Deployment mode string; "company"/"remote"/"enterprise" are
|
|
245
|
+
treated as enterprise. Checked in addition to ``tier``.
|
|
246
|
+
client_host : Resolved remote address (for is_local check).
|
|
247
|
+
roles : Explicit role set for authenticated enterprise actor.
|
|
248
|
+
When None, defaults to {ActorRole.MEMBER}.
|
|
249
|
+
"""
|
|
250
|
+
is_enterprise = tier == "enterprise" or mode in _COMPANY_MODES
|
|
251
|
+
|
|
252
|
+
if not is_enterprise:
|
|
253
|
+
return ActorContext(
|
|
254
|
+
principal_id="local-operator",
|
|
255
|
+
roles=frozenset({ActorRole.OWNER}),
|
|
256
|
+
active_profile_id=profile,
|
|
257
|
+
transport=transport,
|
|
258
|
+
client_host=client_host,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
if not principal:
|
|
262
|
+
return ActorContext(
|
|
263
|
+
principal_id="",
|
|
264
|
+
roles=frozenset({ActorRole.ANONYMOUS}),
|
|
265
|
+
active_profile_id=profile,
|
|
266
|
+
transport=transport,
|
|
267
|
+
client_host=client_host,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
effective_roles: FrozenSet[ActorRole] = (
|
|
271
|
+
roles if roles is not None else frozenset({ActorRole.MEMBER})
|
|
272
|
+
)
|
|
273
|
+
# Store the SHA-256 prefix of the session token (matching the canonical HTTP
|
|
274
|
+
# actor), never the raw token material, for audit-log attribution only.
|
|
275
|
+
import hashlib as _hashlib
|
|
276
|
+
|
|
277
|
+
_session_hash = (
|
|
278
|
+
_hashlib.sha256(session.encode("utf-8")).hexdigest()[:16] if session else ""
|
|
279
|
+
)
|
|
280
|
+
return ActorContext(
|
|
281
|
+
principal_id=principal,
|
|
282
|
+
roles=effective_roles,
|
|
283
|
+
active_profile_id=profile,
|
|
284
|
+
transport=transport,
|
|
285
|
+
client_host=client_host,
|
|
286
|
+
session_token_hash=_session_hash,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# ---------------------------------------------------------------------------
|
|
291
|
+
# admit
|
|
292
|
+
# ---------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
def admit(
|
|
295
|
+
kind: OperationKind,
|
|
296
|
+
actor: ActorContext,
|
|
297
|
+
*,
|
|
298
|
+
resource_ids: tuple[str, ...] = (),
|
|
299
|
+
scope: str | None = None,
|
|
300
|
+
mode: str = "local",
|
|
301
|
+
registry: "OperationPolicyRegistry | None" = None,
|
|
302
|
+
) -> PolicyDecision:
|
|
303
|
+
"""Evaluate kind + actor against the policy registry.
|
|
304
|
+
|
|
305
|
+
Raises AdmissionDenied on deny. Returns PolicyDecision on allow.
|
|
306
|
+
|
|
307
|
+
Parameters
|
|
308
|
+
----------
|
|
309
|
+
kind : Operation being requested.
|
|
310
|
+
actor : Server-derived ActorContext (never from request body).
|
|
311
|
+
resource_ids : Resource identifiers for future ownership checks.
|
|
312
|
+
scope : Scope label for future scoped-read checks.
|
|
313
|
+
mode : Deployment mode string forwarded to registry.evaluate().
|
|
314
|
+
"local"/"personal" → fail-open for unknown kinds.
|
|
315
|
+
"company"/"remote"/"enterprise" → fail-closed.
|
|
316
|
+
registry : Override the default registry (for testing).
|
|
317
|
+
"""
|
|
318
|
+
reg = registry if registry is not None else _DEFAULT_REGISTRY
|
|
319
|
+
decision = reg.evaluate(kind, actor, mode)
|
|
320
|
+
if not decision.allowed:
|
|
321
|
+
raise AdmissionDenied(decision)
|
|
322
|
+
return decision
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# ---------------------------------------------------------------------------
|
|
326
|
+
# @admits decorator for async MCP tools
|
|
327
|
+
# ---------------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
def admits(kind: OperationKind):
|
|
330
|
+
"""Decorator that gates an async MCP tool function via the policy registry.
|
|
331
|
+
|
|
332
|
+
Usage (inside register_*_tools):
|
|
333
|
+
|
|
334
|
+
@server.tool()
|
|
335
|
+
@admits(OperationKind.REMEMBER)
|
|
336
|
+
async def remember(content: str, ...) -> dict:
|
|
337
|
+
...
|
|
338
|
+
|
|
339
|
+
Also registers the tool name in _GATED_MCP_TOOLS at decoration time,
|
|
340
|
+
enabling coverage_self_check() to verify tool inventory at startup.
|
|
341
|
+
|
|
342
|
+
On AdmissionDenied the decorator returns the error dict directly without
|
|
343
|
+
calling the wrapped function.
|
|
344
|
+
"""
|
|
345
|
+
def decorator(fn):
|
|
346
|
+
_GATED_MCP_TOOLS.add(fn.__name__) # register at decoration time
|
|
347
|
+
|
|
348
|
+
@functools.wraps(fn)
|
|
349
|
+
async def wrapper(*args, **kwargs):
|
|
350
|
+
deployment = _resolve_deployment()
|
|
351
|
+
tier = "enterprise" if deployment.is_enterprise else "personal"
|
|
352
|
+
mode = "company" if deployment.is_enterprise else "local"
|
|
353
|
+
actor = resolve_actor(Transport.MCP, tier=tier, mode=mode)
|
|
354
|
+
try:
|
|
355
|
+
admit(kind, actor, mode=mode)
|
|
356
|
+
except AdmissionDenied as exc:
|
|
357
|
+
return {
|
|
358
|
+
"success": False,
|
|
359
|
+
"error": "not_authorized",
|
|
360
|
+
"reason": exc.decision.reason,
|
|
361
|
+
}
|
|
362
|
+
return await fn(*args, **kwargs)
|
|
363
|
+
return wrapper
|
|
364
|
+
return decorator
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
# ---------------------------------------------------------------------------
|
|
368
|
+
# CLI gate helper
|
|
369
|
+
# ---------------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
def gate_cli_mutation(
|
|
372
|
+
kind: OperationKind,
|
|
373
|
+
*,
|
|
374
|
+
principal: str = "",
|
|
375
|
+
roles: FrozenSet[ActorRole] | None = None,
|
|
376
|
+
) -> None:
|
|
377
|
+
"""Gate a CLI mutation command. Exits with code 1 if denied.
|
|
378
|
+
|
|
379
|
+
Call this at the top of any CLI mutation handler that bypasses the daemon.
|
|
380
|
+
In personal mode this is a no-op (OWNER always admitted). In enterprise
|
|
381
|
+
mode without a principal it exits with a clear message.
|
|
382
|
+
|
|
383
|
+
Parameters
|
|
384
|
+
----------
|
|
385
|
+
kind : Operation being performed.
|
|
386
|
+
principal : Authenticated CLI principal (from session store / login token).
|
|
387
|
+
roles : Explicit roles for an authenticated enterprise user.
|
|
388
|
+
"""
|
|
389
|
+
import sys
|
|
390
|
+
deployment = _resolve_deployment()
|
|
391
|
+
tier = "enterprise" if deployment.is_enterprise else "personal"
|
|
392
|
+
mode = "company" if deployment.is_enterprise else "local"
|
|
393
|
+
actor = resolve_actor(
|
|
394
|
+
Transport.CLI,
|
|
395
|
+
tier=tier,
|
|
396
|
+
mode=mode,
|
|
397
|
+
principal=principal,
|
|
398
|
+
roles=roles,
|
|
399
|
+
)
|
|
400
|
+
try:
|
|
401
|
+
admit(kind, actor, mode=mode)
|
|
402
|
+
except AdmissionDenied as exc:
|
|
403
|
+
print(
|
|
404
|
+
f"[slm] Operation denied ({exc.decision.reason}). "
|
|
405
|
+
"This workspace requires authentication. "
|
|
406
|
+
"Log in with 'slm login' or contact your workspace administrator.",
|
|
407
|
+
flush=True,
|
|
408
|
+
)
|
|
409
|
+
sys.exit(1)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# ---------------------------------------------------------------------------
|
|
413
|
+
# Startup coverage self-check (non-vacuous)
|
|
414
|
+
# ---------------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
def coverage_self_check(
|
|
417
|
+
deployment: "DeploymentConfig",
|
|
418
|
+
registry: "OperationPolicyRegistry | None" = None,
|
|
419
|
+
server: "object | None" = None,
|
|
420
|
+
) -> None:
|
|
421
|
+
"""Assert comprehensive policy coverage at daemon startup.
|
|
422
|
+
|
|
423
|
+
Checks:
|
|
424
|
+
1. Every OperationKind has a registered policy (existing check).
|
|
425
|
+
2. No policy has an empty allowed_transports set (unreachable = bug).
|
|
426
|
+
3. Every tool in _REQUIRED_MCP_GATES appears in _GATED_MCP_TOOLS.
|
|
427
|
+
4. (F1) Dynamic: every mutating tool in server._tool_manager._tools
|
|
428
|
+
that is NOT flagged readOnlyHint=True must be in _GATED_MCP_TOOLS.
|
|
429
|
+
|
|
430
|
+
In personal/local mode: logs warnings for any gap (non-fatal).
|
|
431
|
+
In enterprise mode: raises RuntimeError on the first gap (fatal startup).
|
|
432
|
+
|
|
433
|
+
Parameters
|
|
434
|
+
----------
|
|
435
|
+
deployment : Loaded DeploymentConfig (from unified_daemon startup).
|
|
436
|
+
registry : Override the default registry (for testing).
|
|
437
|
+
server : Optional FastMCP server; when supplied, its tool registry
|
|
438
|
+
is enumerated for check 4 (dynamic mutator coverage).
|
|
439
|
+
"""
|
|
440
|
+
reg = registry if registry is not None else _DEFAULT_REGISTRY
|
|
441
|
+
is_enterprise = deployment.is_enterprise
|
|
442
|
+
messages: list[str] = []
|
|
443
|
+
|
|
444
|
+
# Check 1: every OperationKind has a policy entry.
|
|
445
|
+
cov = reg.coverage()
|
|
446
|
+
missing_kinds = [
|
|
447
|
+
kind.value
|
|
448
|
+
for kind in OperationKind
|
|
449
|
+
if not cov.get(kind.value, {}).get("has_policy", False)
|
|
450
|
+
]
|
|
451
|
+
if missing_kinds:
|
|
452
|
+
messages.append(f"policy coverage gap — no policy for: {missing_kinds}")
|
|
453
|
+
|
|
454
|
+
# Check 2: no policy has empty allowed_transports (unreachable).
|
|
455
|
+
empty_transport_kinds = [
|
|
456
|
+
info["kind"]
|
|
457
|
+
for info in cov.values()
|
|
458
|
+
if info.get("has_policy") and not info.get("has_transports", True)
|
|
459
|
+
]
|
|
460
|
+
if empty_transport_kinds:
|
|
461
|
+
messages.append(
|
|
462
|
+
f"empty_transports in policies for: {empty_transport_kinds} "
|
|
463
|
+
"(no reachable transport — these operations can never be invoked)"
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
# Check 3: tool inventory — every required MCP gate is wired.
|
|
467
|
+
ungated = sorted(_REQUIRED_MCP_GATES - _GATED_MCP_TOOLS)
|
|
468
|
+
if ungated:
|
|
469
|
+
messages.append(f"ungated MCP tools (missing @admits): {ungated}")
|
|
470
|
+
|
|
471
|
+
# Check 4 (F1): dynamic discovery — enumerate server tool registry and flag
|
|
472
|
+
# any mutating tool (readOnlyHint / read_only_hint != True) not in
|
|
473
|
+
# _GATED_MCP_TOOLS. See _tool_read_only_hint for mcp 2.0 naming.
|
|
474
|
+
if server is not None:
|
|
475
|
+
try:
|
|
476
|
+
tool_dict = server._tool_manager._tools # type: ignore[attr-defined]
|
|
477
|
+
dynamic_ungated = sorted(
|
|
478
|
+
name
|
|
479
|
+
for name, tool in tool_dict.items()
|
|
480
|
+
if name not in _GATED_MCP_TOOLS
|
|
481
|
+
and _tool_read_only_hint(tool) is not True
|
|
482
|
+
)
|
|
483
|
+
if dynamic_ungated:
|
|
484
|
+
messages.append(
|
|
485
|
+
f"dynamic ungated MCP mutators (not in _GATED_MCP_TOOLS): {dynamic_ungated}"
|
|
486
|
+
)
|
|
487
|
+
except AttributeError:
|
|
488
|
+
logger.debug(
|
|
489
|
+
"admission: server does not expose _tool_manager._tools — skipping dynamic check"
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
if not messages:
|
|
493
|
+
logger.debug("admission: coverage self-check passed (%d kinds)", len(list(OperationKind)))
|
|
494
|
+
return
|
|
495
|
+
|
|
496
|
+
for msg in messages:
|
|
497
|
+
full = f"admission: {msg}"
|
|
498
|
+
if is_enterprise:
|
|
499
|
+
raise RuntimeError(full)
|
|
500
|
+
logger.warning(full)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def enforce_read_scope(
|
|
504
|
+
include_global: "bool | None",
|
|
505
|
+
include_shared: "bool | None",
|
|
506
|
+
*,
|
|
507
|
+
registry: "OperationPolicyRegistry | None" = None,
|
|
508
|
+
) -> "tuple[bool | None, bool | None]":
|
|
509
|
+
"""Clamp cross-profile read flags to the RECALL policy's ``allow_cross_profile``.
|
|
510
|
+
|
|
511
|
+
In personal mode the OWNER is unrestricted — flags pass through unchanged.
|
|
512
|
+
In enterprise mode with ``allow_cross_profile=False`` (the default), any
|
|
513
|
+
explicit ``True`` is silently clamped to ``False`` to prevent client
|
|
514
|
+
escalation beyond the configured scope authority. ``None`` (not specified)
|
|
515
|
+
is left alone so the server default applies.
|
|
516
|
+
"""
|
|
517
|
+
deployment = _resolve_deployment()
|
|
518
|
+
if not deployment.is_enterprise:
|
|
519
|
+
return include_global, include_shared
|
|
520
|
+
|
|
521
|
+
reg = registry if registry is not None else _DEFAULT_REGISTRY
|
|
522
|
+
from superlocalmemory.core.operation_request import OperationKind as _OK
|
|
523
|
+
policy = reg._policies.get(_OK.RECALL)
|
|
524
|
+
if policy is None or policy.allow_cross_profile:
|
|
525
|
+
return include_global, include_shared
|
|
526
|
+
|
|
527
|
+
clamped_global = False if include_global is True else include_global
|
|
528
|
+
clamped_shared = False if include_shared is True else include_shared
|
|
529
|
+
if clamped_global is not include_global or clamped_shared is not include_shared:
|
|
530
|
+
logger.debug(
|
|
531
|
+
"admission: enforce_read_scope clamped cross-profile flags "
|
|
532
|
+
"(include_global=%s→%s, include_shared=%s→%s)",
|
|
533
|
+
include_global, clamped_global, include_shared, clamped_shared,
|
|
534
|
+
)
|
|
535
|
+
return clamped_global, clamped_shared
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
__all__ = [
|
|
539
|
+
"AdmissionDenied",
|
|
540
|
+
"admit",
|
|
541
|
+
"admits",
|
|
542
|
+
"coverage_self_check",
|
|
543
|
+
"enforce_read_scope",
|
|
544
|
+
"gate_cli_mutation",
|
|
545
|
+
"resolve_actor",
|
|
546
|
+
"_resolve_deployment",
|
|
547
|
+
"_GATED_MCP_TOOLS",
|
|
548
|
+
"_REQUIRED_MCP_GATES",
|
|
549
|
+
]
|
|
@@ -16,6 +16,7 @@ Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
|
16
16
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
|
+
import importlib.util
|
|
19
20
|
import logging
|
|
20
21
|
from pathlib import Path
|
|
21
22
|
from typing import TYPE_CHECKING, Any
|
|
@@ -26,6 +27,20 @@ if TYPE_CHECKING:
|
|
|
26
27
|
|
|
27
28
|
logger = logging.getLogger(__name__)
|
|
28
29
|
|
|
30
|
+
|
|
31
|
+
def _module_spec_present(module: str) -> bool:
|
|
32
|
+
"""True if ``module`` can be imported, WITHOUT importing it.
|
|
33
|
+
|
|
34
|
+
``find_spec`` only resolves the loader; it never executes the module, so
|
|
35
|
+
native packages (lancedb, pycozo) cannot spawn background runtimes during
|
|
36
|
+
availability probes. Mirrors component_registry._module_present.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
return importlib.util.find_spec(module) is not None
|
|
40
|
+
except Exception:
|
|
41
|
+
# A broken/partial install can raise inside find_spec — treat as absent.
|
|
42
|
+
return False
|
|
43
|
+
|
|
29
44
|
# ---------------------------------------------------------------------------
|
|
30
45
|
# Global singleton (set by daemon, read by store_pipeline)
|
|
31
46
|
# ---------------------------------------------------------------------------
|
|
@@ -407,11 +422,10 @@ class BackendOrchestrator:
|
|
|
407
422
|
if gb == "sqlite":
|
|
408
423
|
return False
|
|
409
424
|
if gb in ("auto", "cozo"):
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
return False
|
|
425
|
+
# find_spec only resolves the loader — never executes the module.
|
|
426
|
+
# Native pycozo import can spawn background runtimes; do not probe
|
|
427
|
+
# availability by importing (Python 3.14 GC race class).
|
|
428
|
+
return _module_spec_present("pycozo")
|
|
415
429
|
return False
|
|
416
430
|
|
|
417
431
|
def _detect_lancedb(self) -> bool:
|
|
@@ -419,11 +433,10 @@ class BackendOrchestrator:
|
|
|
419
433
|
if vb == "sqlite-vec":
|
|
420
434
|
return False
|
|
421
435
|
if vb in ("auto", "lancedb"):
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
return False
|
|
436
|
+
# find_spec never executes the module. `import lancedb` starts
|
|
437
|
+
# LanceDBBackgroundEventLoop and segfaults under Python 3.14 GC
|
|
438
|
+
# when the full suite races cleanup — never import for a yes/no.
|
|
439
|
+
return _module_spec_present("lancedb")
|
|
427
440
|
return False
|
|
428
441
|
|
|
429
442
|
# ------------------------------------------------------------------
|