superlocalmemory 4.0.5 → 4.0.7

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 (71) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/README.md +8 -9
  3. package/package.json +3 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +1 -1
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +1 -1
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +1 -1
  24. package/pyproject.toml +1 -1
  25. package/src/superlocalmemory/__init__.py +1 -1
  26. package/src/superlocalmemory/access/rbac.py +106 -0
  27. package/src/superlocalmemory/brain/truth.py +80 -10
  28. package/src/superlocalmemory/cli/__main__.py +17 -0
  29. package/src/superlocalmemory/cli/commands.py +28 -3
  30. package/src/superlocalmemory/cli/gdpr_cmd.py +779 -0
  31. package/src/superlocalmemory/cli/gdpr_io.py +109 -0
  32. package/src/superlocalmemory/cli/main.py +85 -0
  33. package/src/superlocalmemory/cli/summary_cmd.py +195 -0
  34. package/src/superlocalmemory/code_graph/bridge/entity_resolver.py +26 -0
  35. package/src/superlocalmemory/code_graph/bridge/event_listeners.py +14 -3
  36. package/src/superlocalmemory/code_graph/bridge/maintenance.py +206 -0
  37. package/src/superlocalmemory/code_graph/config.py +65 -1
  38. package/src/superlocalmemory/code_graph/extractors/__init__.py +17 -0
  39. package/src/superlocalmemory/code_graph/graph_store.py +180 -3
  40. package/src/superlocalmemory/code_graph/parser.py +280 -100
  41. package/src/superlocalmemory/compliance/gdpr.py +358 -0
  42. package/src/superlocalmemory/core/config.py +44 -1
  43. package/src/superlocalmemory/core/engine_wiring.py +5 -1
  44. package/src/superlocalmemory/core/fact_consolidator.py +24 -1
  45. package/src/superlocalmemory/core/maintenance.py +93 -1
  46. package/src/superlocalmemory/core/recall_worker.py +33 -12
  47. package/src/superlocalmemory/infra/backup.py +138 -0
  48. package/src/superlocalmemory/infra/backup_obligations.py +423 -0
  49. package/src/superlocalmemory/learning/engagement.py +165 -0
  50. package/src/superlocalmemory/mcp/tools_code_graph.py +78 -7
  51. package/src/superlocalmemory/mcp/tools_v3.py +20 -6
  52. package/src/superlocalmemory/retrieval/engine.py +21 -0
  53. package/src/superlocalmemory/retrieval/remote_reranker.py +108 -11
  54. package/src/superlocalmemory/server/routes/brain.py +283 -15
  55. package/src/superlocalmemory/server/routes/learning.py +13 -25
  56. package/src/superlocalmemory/server/routes/memories.py +61 -0
  57. package/src/superlocalmemory/server/routes/v3_api.py +171 -60
  58. package/src/superlocalmemory/storage/database.py +36 -0
  59. package/src/superlocalmemory/storage/models.py +12 -4
  60. package/src/superlocalmemory/storage/schema_code_graph.py +44 -1
  61. package/src/superlocalmemory/summaries/__init__.py +37 -0
  62. package/src/superlocalmemory/summaries/base.py +108 -0
  63. package/src/superlocalmemory/summaries/daily_reflection.py +293 -0
  64. package/src/superlocalmemory/summaries/project_work_log.py +424 -0
  65. package/src/superlocalmemory/summaries/session_summary.py +307 -0
  66. package/src/superlocalmemory/ui/css/design-system.css +76 -1
  67. package/src/superlocalmemory/ui/index.html +29 -12
  68. package/src/superlocalmemory/ui/js/fact-detail.js +61 -0
  69. package/src/superlocalmemory/ui/js/od-agents.js +49 -5
  70. package/src/superlocalmemory/ui/js/od-brain.js +257 -77
  71. package/src/superlocalmemory/ui/js/od-graph.js +147 -6
@@ -320,3 +320,168 @@ class EngagementTracker:
320
320
  if raw <= 0:
321
321
  return 0.0
322
322
  return raw / (raw + 20.0)
323
+
324
+
325
+ # ---------------------------------------------------------------------------
326
+ # Derive-on-read engagement — zero hot-path cost (Invariants I1, I3)
327
+ # ---------------------------------------------------------------------------
328
+
329
+ def derive_engagement_from_dbs(
330
+ memory_db_path: "Path | str",
331
+ learning_db_path: "Path | str",
332
+ profile_id: str,
333
+ ) -> "Dict[str, Any]":
334
+ """Derive engagement metrics from tables that already exist — no writes.
335
+
336
+ Source tables
337
+ -------------
338
+ memory.db : atomic_facts
339
+ store_count — live facts (lifecycle in active/warm/cold)
340
+ days_active — distinct calendar days with at least one live fact
341
+ recent_7d — facts created in the last 7 days (drives health)
342
+ learning.db : learning_signals
343
+ recall_count — COUNT(DISTINCT query) for the profile.
344
+ This is a proxy: one distinct query = one recall
345
+ session. If the same query was run N times, it
346
+ counts as 1. The table is populated by the
347
+ post_tool_outcome_hook when Claude Code surfaces
348
+ recall results; it may be empty in environments
349
+ without that hook, in which case recall_count = 0
350
+ while store_count still reflects real activity.
351
+
352
+ Invariant compliance
353
+ --------------------
354
+ I1 — zero writes, no lock acquisition on the recall/remember hot path.
355
+ I3 — no new table or row is ever created; bounded by the existing
356
+ lifecycle-management and retention systems (atomic_facts rows age
357
+ through active → warm → cold → archived via Langevin dynamics;
358
+ learning_signals rows are pruned by the retention sweep).
359
+ I6 — every field carries a named source.
360
+
361
+ health_status (plain language, non-technical)
362
+ ----------------------------------------------
363
+ "ACTIVE" — 10 or more memories added in the last 7 days
364
+ "WARM" — 3–9 memories added in the last 7 days
365
+ "COLD" — 1–2 memories added in the last 7 days
366
+ "INACTIVE" — no new memories in the last 7 days
367
+ """
368
+ memory_db_path = Path(str(memory_db_path))
369
+ learning_db_path = Path(str(learning_db_path))
370
+
371
+ store_count: int = 0
372
+ days_active: int = 0
373
+ recent_7d: int = 0
374
+ recall_count: int = 0
375
+
376
+ # ── memory.db : atomic_facts ──────────────────────────────────────────
377
+ if memory_db_path.exists():
378
+ try:
379
+ # read-only URI; never creates the file, never acquires write lock
380
+ _uri = f"{memory_db_path.resolve().as_uri()}?mode=ro"
381
+ _conn = sqlite3.connect(_uri, uri=True, timeout=1.0)
382
+ _conn.execute("PRAGMA query_only=ON")
383
+ try:
384
+ _tables = {
385
+ r[0]
386
+ for r in _conn.execute(
387
+ "SELECT name FROM sqlite_master WHERE type='table'"
388
+ ).fetchall()
389
+ }
390
+ if "atomic_facts" in _tables:
391
+ # Only live facts (archived = soft-deleted / forgotten).
392
+ # CRIT-fix-3: lifecycle filter avoids counting deleted facts
393
+ # as "stored"; a profile with 1 000 facts all archived
394
+ # should not report store_count=1000 to a non-technical user.
395
+ _r = _conn.execute(
396
+ "SELECT COUNT(*) FROM atomic_facts "
397
+ "WHERE profile_id=? AND lifecycle IN ('active','warm','cold')",
398
+ (profile_id,),
399
+ ).fetchone()
400
+ store_count = _r[0] if _r else 0
401
+
402
+ _r = _conn.execute(
403
+ "SELECT COUNT(DISTINCT SUBSTR(created_at, 1, 10)) "
404
+ "FROM atomic_facts "
405
+ "WHERE profile_id=? AND lifecycle IN ('active','warm','cold')",
406
+ (profile_id,),
407
+ ).fetchone()
408
+ days_active = _r[0] if _r else 0
409
+
410
+ _r = _conn.execute(
411
+ "SELECT COUNT(*) FROM atomic_facts "
412
+ "WHERE profile_id=? "
413
+ "AND lifecycle IN ('active','warm','cold') "
414
+ "AND created_at >= datetime('now', '-7 days')",
415
+ (profile_id,),
416
+ ).fetchone()
417
+ recent_7d = _r[0] if _r else 0
418
+ finally:
419
+ _conn.close()
420
+ except Exception:
421
+ # Any failure (locked, missing, corrupt) → keep zeros;
422
+ # health stays INACTIVE which is the honest fallback.
423
+ pass
424
+
425
+ # ── learning.db : learning_signals (recall proxy) ─────────────────────
426
+ if learning_db_path.exists():
427
+ try:
428
+ _uri = f"{learning_db_path.resolve().as_uri()}?mode=ro"
429
+ _conn = sqlite3.connect(_uri, uri=True, timeout=1.0)
430
+ _conn.execute("PRAGMA query_only=ON")
431
+ try:
432
+ _tables = {
433
+ r[0]
434
+ for r in _conn.execute(
435
+ "SELECT name FROM sqlite_master WHERE type='table'"
436
+ ).fetchall()
437
+ }
438
+ if "learning_signals" in _tables:
439
+ # CRIT-fix-1: label is "distinct queries" not raw row count.
440
+ # One query that surfaces 10 facts writes 10 rows; COUNT(*)
441
+ # would inflate the number by 10×. COUNT(DISTINCT query)
442
+ # approximates "sessions where you asked for something"
443
+ # which is the intent of recall_count for non-technical users.
444
+ _r = _conn.execute(
445
+ "SELECT COUNT(DISTINCT query) FROM learning_signals "
446
+ "WHERE profile_id=?",
447
+ (profile_id,),
448
+ ).fetchone()
449
+ recall_count = _r[0] if _r else 0
450
+ finally:
451
+ _conn.close()
452
+ except Exception:
453
+ pass
454
+
455
+ # ── health derivation (plain language) ───────────────────────────────
456
+ if recent_7d >= _ACTIVE_THRESHOLD:
457
+ health_status = "ACTIVE"
458
+ elif recent_7d >= _WARM_THRESHOLD:
459
+ health_status = "WARM"
460
+ elif recent_7d >= 1:
461
+ health_status = "COLD"
462
+ else:
463
+ health_status = "INACTIVE"
464
+
465
+ total_events = store_count # recalls add to proxy separately via recall_count
466
+ memories_per_day = (
467
+ round(store_count / days_active, 1) if days_active > 0 else 0
468
+ )
469
+ raw = (
470
+ 0.4 * recall_count
471
+ + 0.3 * store_count
472
+ + 0.1 * days_active
473
+ )
474
+ score = (raw / (raw + 20.0)) if raw > 0 else 0.0
475
+
476
+ return {
477
+ "health_status": health_status,
478
+ "days_active": days_active,
479
+ "memories_per_day": memories_per_day,
480
+ "total_events": total_events,
481
+ "recall_count": recall_count,
482
+ "store_count": store_count,
483
+ "session_count": 0, # not derivable without dedicated writes
484
+ "engagement_score": round(score, 4),
485
+ # I6 provenance — every figure is traceable to a named source table
486
+ "source": "memory.db:atomic_facts,learning.db:learning_signals",
487
+ }
@@ -75,11 +75,36 @@ def _graph_not_built_error() -> dict[str, Any]:
75
75
  }
76
76
 
77
77
 
78
+ def _bridge_is_enabled() -> bool:
79
+ """Whether the code↔memory bridge is switched on in the saved settings."""
80
+ try:
81
+ cfg = _get_service()
82
+ if cfg is not None and getattr(cfg.config, "bridge_enabled", False):
83
+ return True
84
+ # No live service yet (e.g. a tool called before any build): read the
85
+ # saved settings directly rather than reporting "off" by default.
86
+ from superlocalmemory.code_graph.config import CodeGraphConfig
87
+ return bool(CodeGraphConfig.load().bridge_enabled)
88
+ except Exception:
89
+ return False
90
+
91
+
78
92
  def _bridge_not_enabled_error() -> dict[str, Any]:
79
- """Standard error when bridge is not enabled."""
93
+ """Standard error when the code↔memory bridge is not enabled.
94
+
95
+ The remediation text used to name ``code_graph.bridge.enabled``, which is not
96
+ a key that exists anywhere. The real field is ``bridge_enabled`` in
97
+ ``code_graph_config.json``, so anyone who followed this message edited
98
+ nothing that mattered. This helper was also never called from any tool, so
99
+ the message could not appear even when it was correct.
100
+ """
80
101
  return {
81
102
  "success": False,
82
- "error": "Bridge not enabled. Set code_graph.bridge.enabled = true in config.",
103
+ "error": (
104
+ 'Code↔memory bridge not enabled. Set "bridge_enabled": true in '
105
+ "~/.superlocalmemory/code_graph_config.json, then run "
106
+ "build_code_graph. Links are created during background maintenance."
107
+ ),
83
108
  }
84
109
 
85
110
 
@@ -167,7 +192,13 @@ def register_code_graph_tools(server, get_engine: Callable) -> None:
167
192
  p.strip() for p in exclude_patterns.split(",") if p.strip()
168
193
  )
169
194
 
170
- config = CodeGraphConfig(**config_kwargs)
195
+ # Start from the user's saved settings, then apply this call's
196
+ # arguments. Previously this constructed CodeGraphConfig(**kwargs)
197
+ # from scratch, so every field the caller did not name reverted to a
198
+ # class default — including bridge_enabled, which the setup wizard
199
+ # writes to code_graph_config.json. A user who enabled the code graph
200
+ # during setup therefore had the flag on disk and off in every build.
201
+ config = CodeGraphConfig.load(**config_kwargs)
171
202
  global _service
172
203
  _service = CodeGraphService(config)
173
204
 
@@ -204,8 +235,12 @@ def register_code_graph_tools(server, get_engine: Callable) -> None:
204
235
  if fp in file_groups:
205
236
  file_groups[fp][1].append(e)
206
237
 
207
- for fp, (ns, es, fr) in file_groups.items():
208
- store.store_file_nodes_edges(fp, ns, es, fr)
238
+ # Two-phase commit: all nodes first, then edges.
239
+ # This makes storage order-independent so cross-file CALLS edges
240
+ # (e.g., a.py calls bar() defined in b.py) are never silently
241
+ # dropped because of the file iteration order.
242
+ batch = [(fp, ns, es, fr) for fp, (ns, es, fr) in file_groups.items()]
243
+ store.commit_build_batch(batch)
209
244
 
210
245
  # Build in-memory graph
211
246
  engine = GraphEngine(store)
@@ -357,11 +392,14 @@ def register_code_graph_tools(server, get_engine: Callable) -> None:
357
392
  continue
358
393
  try:
359
394
  source = full.read_bytes()
360
- file_nodes, file_edges = parser.parse_file(
395
+ file_nodes, file_edges, file_import_map = parser.parse_file(
361
396
  Path(fp), source, lang
362
397
  )
363
398
  import hashlib
364
399
  from superlocalmemory.code_graph.models import FileRecord
400
+ from superlocalmemory.code_graph.parser import (
401
+ _clean_and_resolve_edges,
402
+ )
365
403
  fr = FileRecord(
366
404
  file_path=fp,
367
405
  content_hash=hashlib.sha256(source).hexdigest(),
@@ -371,7 +409,27 @@ def register_code_graph_tools(server, get_engine: Callable) -> None:
371
409
  edge_count=len(file_edges),
372
410
  last_indexed=time.time(),
373
411
  )
374
- store.store_file_nodes_edges(fp, file_nodes, file_edges, fr)
412
+ # Wire the resolver for the incremental path.
413
+ # parse_all has _clean_and_resolve_edges built in, but
414
+ # update_code_graph goes through parse_file which emits raw
415
+ # placeholder targets (__call__<name>). Without resolution,
416
+ # Fix B (defensive filter) drops ALL CALLS edges — silent
417
+ # data loss on every incremental update.
418
+ #
419
+ # Load the full DB node set as the resolution universe so
420
+ # Strategy 3 (global heuristic) can match cross-file calls.
421
+ db_nodes, _ = store.get_all_nodes_and_edges()
422
+ resolution_universe = list(file_nodes) + [
423
+ n for n in db_nodes if n.file_path != fp
424
+ ]
425
+ resolved_edges = _clean_and_resolve_edges(
426
+ resolution_universe,
427
+ list(file_edges),
428
+ {fp: file_import_map},
429
+ repo,
430
+ config,
431
+ )
432
+ store.store_file_nodes_edges(fp, file_nodes, resolved_edges, fr)
375
433
  except Exception as exc:
376
434
  logger.warning("Failed to update %s: %s", fp, exc)
377
435
 
@@ -700,6 +758,11 @@ def register_code_graph_tools(server, get_engine: Callable) -> None:
700
758
  "total_edges": stats.get("edges", 0),
701
759
  "total_code_memory_links": total_links,
702
760
  "stale_links": stale_links,
761
+ # Without this, total_code_memory_links == 0 is ambiguous: it
762
+ # means either "no memory mentions your code" or "the feature
763
+ # that creates links is switched off". Those call for opposite
764
+ # actions, so the reader has to be told which one it is.
765
+ "bridge_enabled": _bridge_is_enabled(),
703
766
  "built": stats.get("built", False),
704
767
  "db_path": stats.get("db_path", ""),
705
768
  }
@@ -1487,6 +1550,14 @@ def register_code_graph_tools(server, get_engine: Callable) -> None:
1487
1550
  if err is not None:
1488
1551
  return err
1489
1552
 
1553
+ # Every answer this tool can give comes from code_memory_links, and
1554
+ # only the bridge populates that table. With the bridge off the query
1555
+ # returns an empty list, which reads as "nothing is stale" — the
1556
+ # strongest possible reassurance, produced by a feature that never
1557
+ # ran. Say so instead.
1558
+ if not _bridge_is_enabled():
1559
+ return _bridge_not_enabled_error()
1560
+
1490
1561
  db = _get_db()
1491
1562
 
1492
1563
  if scope == "all":
@@ -55,9 +55,12 @@ def register_v3_tools(server, get_engine: Callable) -> None:
55
55
  async def set_mode(mode: str) -> dict:
56
56
  """Switch operating mode (a, b, or c).
57
57
 
58
- Mode A: Local Guardian (zero LLM, local embeddings only).
59
- Mode B: Smart Local (local Ollama LLM, on-device inference).
60
- Mode C: Full Power (configured cloud LLM provider, best accuracy).
58
+ Mode A (Local Guardian): Nothing leaves this device. No AI language
59
+ model runs. Fastest and most private.
60
+ Mode B: All data stays on this device. Uses a local Ollama AI model
61
+ to improve recall quality. Requires Ollama installed.
62
+ Mode C: Uses a cloud AI provider (OpenAI, Anthropic, …) for best
63
+ recall quality. Queries leave this device; API key required.
61
64
 
62
65
  Resets the engine to apply the new mode configuration.
63
66
 
@@ -377,8 +380,19 @@ def register_v3_tools(server, get_engine: Callable) -> None:
377
380
  def _mode_description(mode: str) -> str:
378
381
  """Human-readable capability description for a mode (never a legal claim)."""
379
382
  descriptions = {
380
- "a": "Local Guardian: zero LLM, local embeddings only",
381
- "b": "Smart Local: local Ollama LLM, on-device inference",
382
- "c": "Full Power: configured cloud LLM provider, best accuracy",
383
+ "a": (
384
+ "Local Guardian on-device only: no AI language model runs and "
385
+ "nothing leaves this device. Fastest and most private."
386
+ ),
387
+ "b": (
388
+ "Smart Local — on-device plus a local Ollama model: better recall "
389
+ "quality, and nothing leaves this device. Requires Ollama to be "
390
+ "installed and running."
391
+ ),
392
+ "c": (
393
+ "Full Power — uses a cloud AI provider (OpenAI, Anthropic, …) for "
394
+ "the best recall quality. Your queries leave this device and an "
395
+ "API key is required."
396
+ ),
383
397
  }
384
398
  return descriptions.get(mode, "Unknown mode")
@@ -1066,6 +1066,27 @@ class RetrievalEngine:
1066
1066
  if not applied:
1067
1067
  return fused, False, status
1068
1068
 
1069
+ # The worker can report applied=True while returning scores=null — the
1070
+ # subprocess answers, so the call "succeeded", but there is nothing to
1071
+ # score with. Iterating None here raised TypeError from OUTSIDE the
1072
+ # try/except above (which only wraps the rerank call itself), so the
1073
+ # error escaped into the recall path rather than degrading to the fused
1074
+ # ordering. Fail soft: reranking is a quality improvement on top of a
1075
+ # correct result set, never a correctness requirement.
1076
+ # `not scored` covers None AND an empty sequence. An empty list is the
1077
+ # same defect wearing different clothes: the worker says applied=True but
1078
+ # supplied nothing to rank with. Guarding only None would let [] through
1079
+ # to build an empty score_map, and every candidate would then be scored
1080
+ # against a degenerate min/max — silently shrinking the fused component
1081
+ # by (1 - alpha) while still reporting the rerank as applied.
1082
+ if not scored:
1083
+ logger.warning(
1084
+ "Cross-encoder worker reported applied=True with %s scores; "
1085
+ "falling back to fused ranking for this query.",
1086
+ "null" if scored is None else "empty",
1087
+ )
1088
+ return fused, False, "worker_null_scores"
1089
+
1069
1090
  score_map = {fact.fact_id: score for fact, score in scored}
1070
1091
 
1071
1092
  # Min-max normalize CE scores to [0, 1] within the batch instead of
@@ -110,12 +110,30 @@ def is_remote_cross_encoder_backend(backend: str) -> bool:
110
110
  return (backend or "").strip().lower() in REMOTE_CROSS_ENCODER_BACKENDS
111
111
 
112
112
 
113
- def validate_remote_reranker_config(backend: str, endpoint: str) -> str | None:
113
+ def validate_remote_reranker_config(
114
+ backend: str,
115
+ endpoint: str,
116
+ trust_plain_http_lan: bool = True,
117
+ ) -> str | None:
114
118
  """Return an actionable error string, or None when the pair is coherent.
115
119
 
116
120
  Covers the issue-#103 leftover directly: an endpoint configured against a
117
121
  LOCAL backend used to be dropped on the floor by ``SLMConfig.load``. It now
118
122
  produces a named error naming both keys and the exact edit to make.
123
+
124
+ Args:
125
+ backend: Value of ``retrieval.cross_encoder_backend``.
126
+ endpoint: Value of ``retrieval.cross_encoder_endpoint``.
127
+ trust_plain_http_lan: When True (the default), numeric RFC1918/ULA/
128
+ link-local addresses may use plain HTTP — the same security posture
129
+ as the local reranker, where memory text only crosses loopback.
130
+ Set to False in hardened deployments (zero-trust networks, shared
131
+ colocation) to require HTTPS for all non-loopback hosts.
132
+
133
+ Threat model note: trusting a private-LAN address does NOT prevent a
134
+ MITM attack by an adversary on the same physical LAN (e.g. via ARP
135
+ spoofing). This flag means "the LAN is under my control and I accept that
136
+ risk." It is not a claim that RFC1918 traffic is cryptographically secure.
119
137
  """
120
138
  backend = (backend or "").strip()
121
139
  endpoint = (endpoint or "").strip()
@@ -139,11 +157,23 @@ def validate_remote_reranker_config(backend: str, endpoint: str) -> str | None:
139
157
  )
140
158
  if not remote:
141
159
  return None
142
- return _validate_endpoint_url(endpoint)
143
-
144
-
145
- def _validate_endpoint_url(endpoint: str) -> str | None:
146
- """Scheme/host allow-listing for the operator-supplied rerank URL."""
160
+ return _validate_endpoint_url(endpoint, trust_plain_http_lan=trust_plain_http_lan)
161
+
162
+
163
+ def _validate_endpoint_url(
164
+ endpoint: str,
165
+ trust_plain_http_lan: bool = True,
166
+ ) -> str | None:
167
+ """Scheme/host allow-listing for the operator-supplied rerank URL.
168
+
169
+ Plain-HTTP allowances (most-to-least trusted):
170
+ 1. Loopback (127.x, ::1, localhost) — always allowed.
171
+ 2. Numeric RFC1918/ULA/link-local addresses — allowed when
172
+ ``trust_plain_http_lan`` is True (the default). Only numeric
173
+ addresses qualify; bare hostnames are never trusted because DNS is
174
+ mutable and not a trust boundary.
175
+ 3. Everything else (public IPs, bare hostnames) — always requires HTTPS.
176
+ """
147
177
  try:
148
178
  parsed = urlparse(endpoint)
149
179
  except ValueError as exc:
@@ -177,11 +207,39 @@ def _validate_endpoint_url(endpoint: str) -> str | None:
177
207
  "retrieval.cross_encoder_api_key; it is sent as a Bearer header "
178
208
  "and never logged."
179
209
  )
180
- if parsed.scheme == "http" and not _is_loopback_host(parsed.hostname):
210
+ if parsed.scheme == "http":
211
+ hostname = parsed.hostname
212
+ if _is_loopback_host(hostname):
213
+ return None # loopback always allowed regardless of trust flag
214
+ if trust_plain_http_lan and _is_private_lan_host(hostname):
215
+ # Numeric private address on an operator-trusted LAN. Threat model:
216
+ # an attacker on the same physical LAN can still MITM plain HTTP
217
+ # (ARP spoofing). This is allowed because the LAN is assumed to be
218
+ # under the operator's control. Set trust_plain_http_lan=False in
219
+ # hardened/zero-trust environments.
220
+ return None
221
+ if not _is_private_lan_host(hostname):
222
+ # Public IP, CGNAT, or a bare hostname (DNS not trusted as a
223
+ # proof of locality). Bare hostnames that happen to resolve to
224
+ # private IPs are NOT trusted: DNS can be poisoned or changed,
225
+ # so only provably-private numeric addresses are accepted.
226
+ return (
227
+ "retrieval.cross_encoder_endpoint must use HTTPS for this "
228
+ "host. Plain HTTP is allowed only for loopback "
229
+ "(127.x/::1/localhost) and numeric private-LAN addresses "
230
+ "(RFC1918: 10.x, 172.16-31.x, 192.168.x; IPv6 ULA fc00::/7; "
231
+ "link-local 169.254.x/fe80::). "
232
+ "Bare hostnames are not trusted even if they resolve to a "
233
+ "private IP — use a numeric address or configure HTTPS."
234
+ )
235
+ # Private-LAN address but trust_plain_http_lan is False (hardened mode)
181
236
  return (
182
- "retrieval.cross_encoder_endpoint must use HTTPS for non-loopback "
183
- "hosts because recall queries and candidate memory text cross this "
184
- "connection. Plain HTTP is allowed only for localhost/loopback."
237
+ "retrieval.cross_encoder_endpoint uses plain HTTP to a "
238
+ "private-LAN address. HTTPS is required because "
239
+ "retrieval.trust_plain_http_lan is set to false. "
240
+ "Either configure a TLS-terminating proxy on the reranker, or "
241
+ "set retrieval.trust_plain_http_lan=true to permit plain HTTP "
242
+ "within your private network (default for new installs)."
185
243
  )
186
244
  return None
187
245
 
@@ -197,6 +255,42 @@ def _is_loopback_host(hostname: str) -> bool:
197
255
  return False
198
256
 
199
257
 
258
+ def _is_private_lan_host(hostname: str) -> bool:
259
+ """True only for numeric private-range addresses (RFC1918, ULA, link-local).
260
+
261
+ Deliberate non-DNS: bare hostnames (e.g. ``my-reranker.lan``) return False
262
+ even if they currently resolve to a private IP. DNS is mutable and not a
263
+ trust boundary — an adversary who can influence DNS resolution can redirect
264
+ the endpoint to a public host, defeating the locality check. Only numeric
265
+ addresses are provably bound to a private range at configuration time.
266
+
267
+ Accepted ranges (Python 3.11+ ``ipaddress.is_private``):
268
+ IPv4 RFC1918: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
269
+ IPv4 link-local: 169.254.0.0/16
270
+ IPv6 ULA: fc00::/7 (includes fd00::/8)
271
+ IPv6 link-local: fe80::/10
272
+
273
+ Excluded ranges (not accepted for plain HTTP):
274
+ CGNAT 100.64.0.0/10 — ISP-shared address space, not operator-controlled
275
+ 172.15.0.0/8 and 172.32.0.0/8 — outside the 172.16.0.0/12 boundary
276
+ Public unicast addresses
277
+
278
+ IPv4-mapped IPv6 addresses (``::ffff:192.168.1.1``) are unwrapped to their
279
+ IPv4 equivalent before the range check, so they are handled consistently.
280
+ """
281
+ host = (hostname or "").rstrip(".").lower()
282
+ try:
283
+ addr = ipaddress.ip_address(host)
284
+ except ValueError:
285
+ # Not a numeric address — bare hostname, not provably private
286
+ return False
287
+ # Unwrap IPv4-mapped IPv6 (::ffff:192.168.1.1 → 192.168.1.1) so the
288
+ # RFC1918 check applies to the IPv4 portion.
289
+ if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
290
+ addr = addr.ipv4_mapped
291
+ return addr.is_private
292
+
293
+
200
294
  def normalize_rerank_endpoint(endpoint: str) -> str:
201
295
  """Append ``/rerank`` when the URL stops at the API root.
202
296
 
@@ -370,8 +464,11 @@ class RemoteReranker:
370
464
  api_key: str = "",
371
465
  backend: str = "openai",
372
466
  timeout_seconds: float = _DEFAULT_READ_TIMEOUT_S,
467
+ trust_plain_http_lan: bool = True,
373
468
  ) -> None:
374
- error = validate_remote_reranker_config(backend, endpoint)
469
+ error = validate_remote_reranker_config(
470
+ backend, endpoint, trust_plain_http_lan=trust_plain_http_lan,
471
+ )
375
472
  if error:
376
473
  raise RemoteRerankerConfigError(error)
377
474