fix(retrieval): secure daemon IPC messages
This commit is contained in:
1 parent
c9612cac42
commit
c27e32f5b2
2 files changed
+41
-12
No files matched your search
@@ -8,7 +8,7 @@
|
|||||||
- [ ] Preserve pre-existing MCPorter server entries. `ensure_mcporter()` adds a server and claims ownership without checking existing configuration; if the add succeeds for an existing name, a later removal deletes that user's entry. (`scripts/lib/managed-extensions.py:395`, `scripts/lib/managed-extensions.py:263`)
|
- [ ] Preserve pre-existing MCPorter server entries. `ensure_mcporter()` adds a server and claims ownership without checking existing configuration; if the add succeeds for an existing name, a later removal deletes that user's entry. (`scripts/lib/managed-extensions.py:395`, `scripts/lib/managed-extensions.py:263`)
|
||||||
- [ ] Preserve pre-existing global CLI packages. `ensure_cli()` and `ensure_qmd()` install and claim npm packages without checking whether they were already installed; later removal can uninstall a user's package. (`scripts/lib/managed-extensions.py:342`, `scripts/lib/managed-extensions.py:378`)
|
- [ ] Preserve pre-existing global CLI packages. `ensure_cli()` and `ensure_qmd()` install and claim npm packages without checking whether they were already installed; later removal can uninstall a user's package. (`scripts/lib/managed-extensions.py:342`, `scripts/lib/managed-extensions.py:378`)
|
||||||
- [x] Fetch skill archives by an immutable commit and record that same commit. The archive uses GitHub `HEAD`, while `resolvedRevision` comes from a separate later request, so the recorded revision may not identify the installed payload. (`scripts/lib/managed-extensions.py:94`, `scripts/lib/managed-extensions.py:140`, `scripts/lib/managed-extensions.py:409`)
|
- [x] Fetch skill archives by an immutable commit and record that same commit. The archive uses GitHub `HEAD`, while `resolvedRevision` comes from a separate later request, so the recorded revision may not identify the installed payload. (`scripts/lib/managed-extensions.py:94`, `scripts/lib/managed-extensions.py:140`, `scripts/lib/managed-extensions.py:409`)
|
||||||
- [ ] Protect the semantic retrieval daemon's IPC protocol. It uses a hardcoded authentication key and `multiprocessing.connection.recv()`, which deserializes messages; a process that can reach its socket or pipe can submit arbitrary roots and potentially crafted objects. Use a private per-install credential and a non-pickle wire format, or restrict the endpoint to the current user. (`shared/retrieval/server.py:47`, `shared/retrieval/server.py:306`, `shared/retrieval/server.py:314`)
|
- [x] Protect the semantic retrieval daemon's IPC protocol. It uses a hardcoded authentication key and `multiprocessing.connection.recv()`, which deserializes messages; a process that can reach its socket or pipe can submit arbitrary roots and potentially crafted objects. Use a private per-install credential and a non-pickle wire format, or restrict the endpoint to the current user. (`shared/retrieval/server.py:47`, `shared/retrieval/server.py:306`, `shared/retrieval/server.py:314`)
|
||||||
- [x] Write semantic retrieval ownership state atomically. `save_state()` replaces the JSON with a direct write, unlike the extension and selection ledgers; interruption can leave it unreadable and block later reconciliation. (`scripts/lib/semantic-retrieval.py:63`)
|
- [x] Write semantic retrieval ownership state atomically. `save_state()` replaces the JSON with a direct write, unlike the extension and selection ledgers; interruption can leave it unreadable and block later reconciliation. (`scripts/lib/semantic-retrieval.py:63`)
|
||||||
|
|
||||||
## Menus and reconciliation
|
## Menus and reconciliation
|
||||||
|
|||||||
+40
-11
@@ -9,11 +9,13 @@ import hashlib
|
|||||||
import heapq
|
import heapq
|
||||||
import json
|
import json
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
|
from multiprocessing import AuthenticationError
|
||||||
from multiprocessing.connection import Client, Listener
|
from multiprocessing.connection import Client, Listener
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
@@ -44,7 +46,7 @@ MAX_FILES_ENV = "SEMANTIC_RETRIEVAL_MAX_FILES"
|
|||||||
# that would reload both models from disk before it can answer a single query.
|
# that would reload both models from disk before it can answer a single query.
|
||||||
DAEMON_IDLE_SECONDS = 1800
|
DAEMON_IDLE_SECONDS = 1800
|
||||||
DAEMON_READY_TIMEOUT_SECONDS = 120
|
DAEMON_READY_TIMEOUT_SECONDS = 120
|
||||||
DAEMON_AUTHKEY = b"ai-config-semantic-retrieval"
|
DAEMON_MESSAGE_LIMIT = 16 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
def runtime_python(script=__file__, executable=sys.executable, system=os.name):
|
def runtime_python(script=__file__, executable=sys.executable, system=os.name):
|
||||||
@@ -232,12 +234,35 @@ def daemon_address(data_dir: Path) -> str:
|
|||||||
return str(data_dir / f"daemon-{digest}.sock")
|
return str(data_dir / f"daemon-{digest}.sock")
|
||||||
|
|
||||||
|
|
||||||
def request_daemon(address: str, payload: dict):
|
def daemon_authkey(data_dir: Path) -> bytes:
|
||||||
with Client(address, authkey=DAEMON_AUTHKEY) as connection:
|
if data_dir.is_symlink():
|
||||||
connection.send(payload)
|
raise OSError("Semantic retrieval data directory must not be a link.")
|
||||||
|
data_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||||
|
path = data_dir / "daemon.key"
|
||||||
|
try:
|
||||||
|
descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
||||||
|
except FileExistsError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
with os.fdopen(descriptor, "wb") as stream:
|
||||||
|
stream.write(os.urandom(32))
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
metadata = path.lstat()
|
||||||
|
if not stat.S_ISREG(metadata.st_mode) or (os.name != "nt" and metadata.st_mode & 0o077):
|
||||||
|
raise OSError("Semantic retrieval daemon credential is not private.")
|
||||||
|
key = path.read_bytes()
|
||||||
|
if len(key) != 32:
|
||||||
|
raise OSError("Semantic retrieval daemon credential is incomplete.")
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def request_daemon(address: str, payload: dict, data_dir: Path):
|
||||||
|
with Client(address, authkey=daemon_authkey(data_dir)) as connection:
|
||||||
|
connection.send_bytes(json.dumps(payload).encode("utf-8"))
|
||||||
if not connection.poll(CLI_TIMEOUT_SECONDS):
|
if not connection.poll(CLI_TIMEOUT_SECONDS):
|
||||||
raise TimeoutError(f"Semantic retrieval daemon exceeded the {CLI_TIMEOUT_SECONDS} second timeout.")
|
raise TimeoutError(f"Semantic retrieval daemon exceeded the {CLI_TIMEOUT_SECONDS} second timeout.")
|
||||||
success, result = connection.recv()
|
success, result = json.loads(connection.recv_bytes(DAEMON_MESSAGE_LIMIT))
|
||||||
if not success:
|
if not success:
|
||||||
raise RuntimeError(str(result))
|
raise RuntimeError(str(result))
|
||||||
return result
|
return result
|
||||||
@@ -270,7 +295,9 @@ def try_daemon(root: Path, data_dir: Path, model_cache: Path, action: str, query
|
|||||||
address = daemon_address(data_dir)
|
address = daemon_address(data_dir)
|
||||||
payload = {"action": action, "root": str(root), "query": query, "top_k": top_k}
|
payload = {"action": action, "root": str(root), "query": query, "top_k": top_k}
|
||||||
try:
|
try:
|
||||||
return request_daemon(address, payload)
|
return request_daemon(address, payload, data_dir)
|
||||||
|
except AuthenticationError:
|
||||||
|
return None
|
||||||
except (OSError, EOFError):
|
except (OSError, EOFError):
|
||||||
pass
|
pass
|
||||||
if not spawn_daemon(data_dir, model_cache):
|
if not spawn_daemon(data_dir, model_cache):
|
||||||
@@ -278,8 +305,8 @@ def try_daemon(root: Path, data_dir: Path, model_cache: Path, action: str, query
|
|||||||
deadline = time.monotonic() + DAEMON_READY_TIMEOUT_SECONDS
|
deadline = time.monotonic() + DAEMON_READY_TIMEOUT_SECONDS
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
try:
|
try:
|
||||||
return request_daemon(address, payload)
|
return request_daemon(address, payload, data_dir)
|
||||||
except (OSError, EOFError):
|
except (OSError, EOFError, AuthenticationError):
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -303,7 +330,7 @@ def run_daemon(data_dir: Path, model_cache: Path):
|
|||||||
os._exit(0)
|
os._exit(0)
|
||||||
|
|
||||||
threading.Thread(target=watchdog, daemon=True).start()
|
threading.Thread(target=watchdog, daemon=True).start()
|
||||||
listener = Listener(address, family="AF_PIPE" if os.name == "nt" else "AF_UNIX", authkey=DAEMON_AUTHKEY)
|
listener = Listener(address, family="AF_PIPE" if os.name == "nt" else "AF_UNIX", authkey=daemon_authkey(data_dir))
|
||||||
daemon_lock_path(data_dir).unlink(missing_ok=True)
|
daemon_lock_path(data_dir).unlink(missing_ok=True)
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
@@ -311,7 +338,9 @@ def run_daemon(data_dir: Path, model_cache: Path):
|
|||||||
try:
|
try:
|
||||||
response = (True, None)
|
response = (True, None)
|
||||||
try:
|
try:
|
||||||
request = connection.recv()
|
request = json.loads(connection.recv_bytes(DAEMON_MESSAGE_LIMIT))
|
||||||
|
if not isinstance(request, dict) or request.get("action") not in ("search", "rebuild") or not isinstance(request.get("root"), str):
|
||||||
|
raise ValueError("Invalid semantic retrieval daemon request.")
|
||||||
index = indexes.get(request["root"])
|
index = indexes.get(request["root"])
|
||||||
if index is None:
|
if index is None:
|
||||||
if len(indexes) >= 8:
|
if len(indexes) >= 8:
|
||||||
@@ -326,7 +355,7 @@ def run_daemon(data_dir: Path, model_cache: Path):
|
|||||||
continue
|
continue
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
response = (False, str(error))
|
response = (False, str(error))
|
||||||
connection.send(response)
|
connection.send_bytes(json.dumps(response).encode("utf-8"))
|
||||||
except (OSError, EOFError):
|
except (OSError, EOFError):
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
Reference in new issue
Block a user