superlocalmemory 4.0.5 → 4.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +57 -0
- package/README.md +10 -6
- package/package.json +3 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/rbac.py +106 -0
- package/src/superlocalmemory/brain/truth.py +80 -10
- package/src/superlocalmemory/cli/__main__.py +17 -0
- package/src/superlocalmemory/cli/commands.py +14 -3
- package/src/superlocalmemory/cli/gdpr_cmd.py +779 -0
- package/src/superlocalmemory/cli/gdpr_io.py +109 -0
- package/src/superlocalmemory/cli/main.py +76 -0
- package/src/superlocalmemory/code_graph/extractors/__init__.py +17 -0
- package/src/superlocalmemory/code_graph/graph_store.py +180 -3
- package/src/superlocalmemory/code_graph/parser.py +280 -100
- package/src/superlocalmemory/compliance/gdpr.py +358 -0
- package/src/superlocalmemory/core/config.py +44 -1
- package/src/superlocalmemory/core/engine_wiring.py +5 -1
- package/src/superlocalmemory/core/maintenance.py +43 -1
- package/src/superlocalmemory/core/recall_worker.py +33 -12
- package/src/superlocalmemory/infra/backup.py +138 -0
- package/src/superlocalmemory/infra/backup_obligations.py +423 -0
- package/src/superlocalmemory/learning/engagement.py +165 -0
- package/src/superlocalmemory/mcp/tools_code_graph.py +31 -4
- package/src/superlocalmemory/mcp/tools_v3.py +20 -6
- package/src/superlocalmemory/retrieval/engine.py +21 -0
- package/src/superlocalmemory/retrieval/remote_reranker.py +108 -11
- package/src/superlocalmemory/server/routes/brain.py +283 -15
- package/src/superlocalmemory/server/routes/learning.py +13 -25
- package/src/superlocalmemory/server/routes/v3_api.py +171 -60
- package/src/superlocalmemory/storage/database.py +36 -0
- package/src/superlocalmemory/storage/models.py +12 -4
- package/src/superlocalmemory/summaries/__init__.py +37 -0
- package/src/superlocalmemory/summaries/base.py +108 -0
- package/src/superlocalmemory/summaries/daily_reflection.py +293 -0
- package/src/superlocalmemory/summaries/project_work_log.py +424 -0
- package/src/superlocalmemory/summaries/session_summary.py +307 -0
- package/src/superlocalmemory/ui/css/design-system.css +76 -1
- package/src/superlocalmemory/ui/index.html +28 -11
- package/src/superlocalmemory/ui/js/od-agents.js +49 -5
- package/src/superlocalmemory/ui/js/od-brain.js +257 -77
- 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
|
+
}
|
|
@@ -204,8 +204,12 @@ def register_code_graph_tools(server, get_engine: Callable) -> None:
|
|
|
204
204
|
if fp in file_groups:
|
|
205
205
|
file_groups[fp][1].append(e)
|
|
206
206
|
|
|
207
|
-
|
|
208
|
-
|
|
207
|
+
# Two-phase commit: all nodes first, then edges.
|
|
208
|
+
# This makes storage order-independent so cross-file CALLS edges
|
|
209
|
+
# (e.g., a.py calls bar() defined in b.py) are never silently
|
|
210
|
+
# dropped because of the file iteration order.
|
|
211
|
+
batch = [(fp, ns, es, fr) for fp, (ns, es, fr) in file_groups.items()]
|
|
212
|
+
store.commit_build_batch(batch)
|
|
209
213
|
|
|
210
214
|
# Build in-memory graph
|
|
211
215
|
engine = GraphEngine(store)
|
|
@@ -357,11 +361,14 @@ def register_code_graph_tools(server, get_engine: Callable) -> None:
|
|
|
357
361
|
continue
|
|
358
362
|
try:
|
|
359
363
|
source = full.read_bytes()
|
|
360
|
-
file_nodes, file_edges = parser.parse_file(
|
|
364
|
+
file_nodes, file_edges, file_import_map = parser.parse_file(
|
|
361
365
|
Path(fp), source, lang
|
|
362
366
|
)
|
|
363
367
|
import hashlib
|
|
364
368
|
from superlocalmemory.code_graph.models import FileRecord
|
|
369
|
+
from superlocalmemory.code_graph.parser import (
|
|
370
|
+
_clean_and_resolve_edges,
|
|
371
|
+
)
|
|
365
372
|
fr = FileRecord(
|
|
366
373
|
file_path=fp,
|
|
367
374
|
content_hash=hashlib.sha256(source).hexdigest(),
|
|
@@ -371,7 +378,27 @@ def register_code_graph_tools(server, get_engine: Callable) -> None:
|
|
|
371
378
|
edge_count=len(file_edges),
|
|
372
379
|
last_indexed=time.time(),
|
|
373
380
|
)
|
|
374
|
-
|
|
381
|
+
# Wire the resolver for the incremental path.
|
|
382
|
+
# parse_all has _clean_and_resolve_edges built in, but
|
|
383
|
+
# update_code_graph goes through parse_file which emits raw
|
|
384
|
+
# placeholder targets (__call__<name>). Without resolution,
|
|
385
|
+
# Fix B (defensive filter) drops ALL CALLS edges — silent
|
|
386
|
+
# data loss on every incremental update.
|
|
387
|
+
#
|
|
388
|
+
# Load the full DB node set as the resolution universe so
|
|
389
|
+
# Strategy 3 (global heuristic) can match cross-file calls.
|
|
390
|
+
db_nodes, _ = store.get_all_nodes_and_edges()
|
|
391
|
+
resolution_universe = list(file_nodes) + [
|
|
392
|
+
n for n in db_nodes if n.file_path != fp
|
|
393
|
+
]
|
|
394
|
+
resolved_edges = _clean_and_resolve_edges(
|
|
395
|
+
resolution_universe,
|
|
396
|
+
list(file_edges),
|
|
397
|
+
{fp: file_import_map},
|
|
398
|
+
repo,
|
|
399
|
+
config,
|
|
400
|
+
)
|
|
401
|
+
store.store_file_nodes_edges(fp, file_nodes, resolved_edges, fr)
|
|
375
402
|
except Exception as exc:
|
|
376
403
|
logger.warning("Failed to update %s: %s", fp, exc)
|
|
377
404
|
|
|
@@ -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
|
|
59
|
-
|
|
60
|
-
Mode
|
|
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":
|
|
381
|
-
|
|
382
|
-
|
|
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(
|
|
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(
|
|
146
|
-
|
|
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"
|
|
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
|
|
183
|
-
"
|
|
184
|
-
"
|
|
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(
|
|
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
|
|