Coverage for src/local_deep_research/security/egress/policy.py: 94%

663 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +0000

1"""Egress policy module: central PDP for search engines, LLM endpoints, 

2embeddings, and URL fetches. 

3 

4This is an in-process correctness guardrail, NOT a hard security boundary. 

5It defends against honest misconfiguration, prompt-injection-induced 

6URL fetches, accidental egress, and the LangGraph silent-expansion bug. 

7It does NOT defend against compromised dependencies, code-execution 

8in the LDR process, or a determined adversary who can modify the policy 

9module itself. Operators needing a hard boundary should layer OS-level 

10controls (network namespaces, firewall rules, restricted Docker). 

11 

12Vocabulary borrowed from XACML / zero-trust: 

13 PDP — Policy Decision Point (the evaluate_* functions in this module) 

14 PEP — Policy Enforcement Point (the call sites that consult the PDP) 

15""" 

16 

17from __future__ import annotations 

18 

19import hashlib 

20import json 

21import socket 

22import threading 

23from collections import OrderedDict 

24from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeout 

25from dataclasses import dataclass, field 

26from enum import Enum 

27from typing import Literal, Optional 

28from urllib.parse import unquote, urlsplit 

29 

30from loguru import logger 

31 

32from ..log_sanitizer import sanitize_for_log 

33from ..network_utils import is_private_ip 

34from ...utilities.type_utils import unwrap_setting 

35 

36 

37# Bounded DNS lookup timeout; getaddrinfo has no native timeout kwarg, so 

38# we run it inside a single-shot ThreadPoolExecutor and abandon the 

39# Future after the timeout elapses. This avoids the previous 

40# socket.setdefaulttimeout() approach, which mutated a process-global 

41# setting and could corrupt unrelated network code under concurrency. 

42_DNS_TIMEOUT_SEC = 2.0 

43 

44 

45# Hard limit: after this many denied fetches in a single run, fail closed 

46# even on otherwise-legal fetches. Prevents an exhaust attack via a malicious 

47# indexed document looping the agent through hundreds of denied URLs. 

48MAX_DENIED_FETCHES_PER_RUN = 50 

49 

50# Only SECURITY-relevant denials count toward the per-run quota. Benign parse 

51# failures (a mailto:/ftp:/tel: link or a malformed href scraped from a page) 

52# are common in legitimate documents and must NOT exhaust the budget — doing 

53# so would make a long PUBLIC_ONLY run start refusing legitimate public URLs 

54# mid-run. These reasons are still audit-logged; they just don't tick the quota. 

55# ``dangerous_scheme`` (javascript:/data:/file:/… hrefs) belongs here too: these 

56# are non-fetchable, non-network schemes scraped from ordinary HTML (onclick 

57# handlers, inline data: URIs), so — like ``unsupported_scheme`` — they can't 

58# cause egress and shouldn't let a doc full of data: URIs exhaust the budget. 

59_NON_QUOTA_DENIAL_REASONS = frozenset( 

60 {"url_malformed", "unsupported_scheme", "no_hostname", "dangerous_scheme"} 

61) 

62 

63 

64class EgressScope(str, Enum): 

65 """User-declared egress boundary for a research run. 

66 

67 - STRICT: only the user's primary engine; no expansion at all. 

68 - PUBLIC_ONLY: any public (external web/academic) engine. 

69 - PRIVATE_ONLY: any private (local collection / library) engine. 

70 - BOTH: INTERNAL ONLY — the resolved scope for an unclassifiable ADAPTIVE 

71 primary (allow any classified engine, no local-inference coupling). It is 

72 NO LONGER a user-selectable scope: `both` was the "muddy middle" (ADR-0007 

73 Problem 3), retired in favour of `adaptive` + per-collection `is_public`. 

74 A residual user `both` (stored / env / queued) is coerced to `adaptive` 

75 by ``context_from_snapshot`` and migration 0019. 

76 - ADAPTIVE: scope FOLLOWS the primary engine — a concrete private 

77 primary behaves as PRIVATE_ONLY, a concrete public primary as 

78 PUBLIC_ONLY, and an unclassifiable primary as BOTH. 

79 The default: most users never touch scope and "it just matches my 

80 main engine." Resolved to a concrete scope at context construction; 

81 the stored EgressContext carries the RESOLVED scope, not ADAPTIVE. 

82 - UNPROTECTED: escape hatch — egress protection is DISABLED for the run. 

83 Any engine / URL / provider is permitted; the hard SSRF and 

84 cloud-metadata blocks in ``evaluate_url`` still apply. Not recommended. 

85 """ 

86 

87 STRICT = "strict" 

88 PUBLIC_ONLY = "public_only" 

89 PRIVATE_ONLY = "private_only" 

90 BOTH = "both" 

91 ADAPTIVE = "adaptive" 

92 UNPROTECTED = "unprotected" 

93 

94 

95USER_SELECTABLE_PROTECTED_SCOPES = frozenset( 

96 { 

97 EgressScope.ADAPTIVE, 

98 EgressScope.PUBLIC_ONLY, 

99 EgressScope.PRIVATE_ONLY, 

100 EgressScope.STRICT, 

101 } 

102) 

103 

104 

105def unprotected_egress_allowed() -> bool: 

106 """Return whether the operator enabled the unprotected escape hatch.""" 

107 from ...settings.env_registry import get_env_setting 

108 

109 return bool(get_env_setting("policy.allow_unprotected_egress", False)) 

110 

111 

112# Code-side single source of truth for the default egress scope, used by 

113# every reader that needs a fallback for a MISSING policy.egress_scope key 

114# (partial snapshots from the programmatic API, un-bootstrapped settings 

115# DBs). Import THIS instead of hardcoding a string literal: scattered 

116# literals are how the registry default ("adaptive") and the code 

117# fallbacks ("both") drifted apart in the first place. Must match the 

118# registered default in defaults/default_settings.json — pinned by 

119# tests/security/test_egress_policy.py:: 

120# test_default_scope_constant_matches_registry. 

121DEFAULT_EGRESS_SCOPE: str = EgressScope.ADAPTIVE.value 

122 

123 

124@dataclass(frozen=True) 

125class EgressContext: 

126 """Frozen per-run policy snapshot. 

127 

128 Constructed once via ``context_from_snapshot()`` at run-start. 

129 The dataclass is frozen, but mutable internals (``_dns_cache``, 

130 ``_fetch_denial_count``) use ``field(init=False, default_factory=...)`` 

131 so they can accumulate state during a run. Counters live inside a 

132 dict because direct ``int`` field reassignment fails on ``frozen=True``. 

133 """ 

134 

135 scope: EgressScope 

136 primary_engine: str 

137 require_local_llm: bool 

138 require_local_embeddings: bool 

139 local_hostnames: tuple[str, ...] = () 

140 username: Optional[str] = None 

141 # Mutable internal state (kept off the public surface). 

142 _dns_cache: dict = field(init=False, default_factory=dict, repr=False) 

143 _fetch_denial_count: dict = field( 

144 init=False, default_factory=lambda: {"count": 0}, repr=False 

145 ) 

146 # Guards _dns_cache + _fetch_denial_count mutations. Scope is the 

147 # cache writes only — DNS I/O must NOT happen while holding the lock, 

148 # or concurrent subagent threads serialize on each other's lookups. 

149 _lock: threading.RLock = field( 

150 init=False, default_factory=threading.RLock, repr=False 

151 ) 

152 

153 

154@dataclass(frozen=True) 

155class Decision: 

156 """Result of a PDP evaluation. ``reason`` is a short machine code 

157 (e.g. ``"unclassified"``, ``"scope_mismatch"``) — never the user's 

158 rejected query or URL content. 

159 """ 

160 

161 allowed: bool 

162 reason: str 

163 

164 

165@dataclass(frozen=True, slots=True) 

166class EngineClassification: 

167 """Public/local classification for one search engine.""" 

168 

169 is_public: bool | None 

170 is_local: bool | None 

171 

172 

173class PolicyDeniedError(RuntimeError): 

174 """Raised by PEPs when a policy decision is hard-stop denial. 

175 

176 Raising (rather than returning a graceful empty result) ensures 

177 consistent denial latency, which mitigates the LangGraph timing-leak 

178 pattern where an LLM could infer policy state from how fast a denied 

179 tool call returns. 

180 """ 

181 

182 def __init__(self, decision: Decision, target: str = ""): 

183 self.decision = decision 

184 self.target = target 

185 super().__init__(f"policy_denied: {decision.reason}") 

186 

187 

188def parse_user_egress_scope( 

189 raw: object, 

190 *, 

191 disabled_unprotected: Literal["reject", "adaptive"] = "reject", 

192) -> EgressScope: 

193 """Parse a user-facing scope and enforce the operator escape-hatch gate. 

194 

195 BOTH is deliberately absent from the protected allowlist: it remains an 

196 internal adaptive-resolution result, not a value users may select. Runtime 

197 readers can request the protective adaptive fallback for stale queued 

198 unprotected values; request and settings writers use the default hard 

199 rejection. 

200 """ 

201 if disabled_unprotected not in {"reject", "adaptive"}: 201 ↛ 202line 201 didn't jump to line 202 because the condition on line 201 was never true

202 raise ValueError("disabled_unprotected must be 'reject' or 'adaptive'") 

203 if not isinstance(raw, str): 

204 raise PolicyDeniedError( 

205 Decision(False, "unknown_egress_scope"), target=str(raw) 

206 ) 

207 normalized = raw.strip().lower() 

208 try: 

209 scope = EgressScope(normalized) 

210 except ValueError as exc: 

211 raise PolicyDeniedError( 

212 Decision(False, "unknown_egress_scope"), target=str(raw) 

213 ) from exc 

214 if scope in USER_SELECTABLE_PROTECTED_SCOPES: 

215 return scope 

216 if scope == EgressScope.UNPROTECTED: 

217 if unprotected_egress_allowed(): 

218 return scope 

219 if disabled_unprotected == "adaptive": 

220 return EgressScope.ADAPTIVE 

221 raise PolicyDeniedError( 

222 Decision(False, "unprotected_egress_disabled"), target=str(raw) 

223 ) 

224 raise PolicyDeniedError( 

225 Decision(False, "unknown_egress_scope"), target=str(raw) 

226 ) 

227 

228 

229def effective_scope_for_display(raw: object) -> str: 

230 """Return a canonical, presentation-safe user scope value. 

231 

232 Invalid values, retired both, and operator-disabled unprotected selections 

233 render as the protective default. This helper is presentation only; 

234 enforcement remains in context_from_snapshot and its PEPs. 

235 """ 

236 try: 

237 return parse_user_egress_scope( 

238 raw, disabled_unprotected="adaptive" 

239 ).value 

240 except PolicyDeniedError: 

241 return DEFAULT_EGRESS_SCOPE 

242 

243 

244def _is_nat64_wrapped_metadata(hostname: str) -> bool: 

245 """True iff ``hostname`` parses as an IPv6 NAT64 address wrapping a 

246 cloud-metadata IPv4 (AWS / Azure / GCE / etc). 

247 

248 The wrapping passes ``is_private_ip`` (because IPv6 link-local 

249 ranges match) but the embedded IPv4 actually reaches the metadata 

250 endpoint. We classify these as PUBLIC so STRICT and PRIVATE_ONLY 

251 refuse to fetch them. 

252 """ 

253 try: 

254 import ipaddress 

255 

256 from ..ssrf_validator import is_nat64_wrapped_metadata_ip 

257 

258 candidate = hostname 

259 if candidate.startswith("[") and candidate.endswith("]"): 

260 candidate = candidate[1:-1] 

261 ip = ipaddress.ip_address(candidate) 

262 return is_nat64_wrapped_metadata_ip(ip) 

263 except (ValueError, TypeError): 

264 return False 

265 except Exception: # pragma: no cover - defensive 

266 return False 

267 

268 

269def _resolve_with_timeout(hostname: str) -> Optional[list]: 

270 """Resolve ``hostname`` via getaddrinfo with a bounded timeout. 

271 

272 Returns the addrinfo list on success or None on timeout / lookup 

273 failure. getaddrinfo has no native timeout, so we drive it from a 

274 single-shot worker thread and abandon the Future on timeout — far 

275 safer than socket.setdefaulttimeout(), which mutates a 

276 process-global setting and races with unrelated network code. 

277 """ 

278 

279 def _do_lookup(): 

280 return socket.getaddrinfo(hostname, None, type=socket.SOCK_STREAM) 

281 

282 # NB: do NOT use ``with ThreadPoolExecutor(...)`` here. The context 

283 # manager's __exit__ calls shutdown(wait=True), which blocks until the 

284 # worker thread finishes — so a hung getaddrinfo would defeat the whole 

285 # point of the timeout (the call would return only after the OS DNS 

286 # timeout, not after _DNS_TIMEOUT_SEC). Instead we abandon the worker on 

287 # timeout via shutdown(wait=False): the caller returns promptly and the 

288 # orphaned thread dies on its own when getaddrinfo eventually returns. 

289 executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="ldr-dns") 

290 future = executor.submit(_do_lookup) 

291 try: 

292 return future.result(timeout=_DNS_TIMEOUT_SEC) 

293 except (FutureTimeout, socket.gaierror, socket.timeout, OSError): 

294 return None 

295 except Exception: # pragma: no cover - defensive 

296 return None 

297 finally: 

298 # wait=False so a timed-out lookup does not re-block here; a 

299 # completed lookup leaves an idle thread that shuts down at once. 

300 executor.shutdown(wait=False) 

301 

302 

303_UNCACHED = object() 

304 

305 

306def _cache_classification( 

307 ctx: EgressContext, hostname: str, value: bool 

308) -> bool: 

309 """First-writer-wins cache set; returns the value now in the cache. 

310 

311 Concurrent subagent threads may classify the same hostname outside the 

312 lock (the DNS lookup is intentionally unsynchronized). If a round-robin 

313 DNS name resolves to a private IP for one thread and a public IP for 

314 another, last-writer-wins would let a later call flip an earlier call's 

315 result — cache *incoherence* that could relax PRIVATE_ONLY/STRICT on a 

316 subsequent fetch. Pinning the first writer's value makes the per-run 

317 classification stable and deterministic regardless of completion order. 

318 """ 

319 with ctx._lock: 

320 existing = ctx._dns_cache.get(hostname, _UNCACHED) 

321 if existing is not _UNCACHED: 

322 return existing 

323 ctx._dns_cache[hostname] = value 

324 return value 

325 

326 

327def _classify_host( 

328 hostname: str, ctx: EgressContext, allow_dns: bool = True 

329) -> Optional[bool]: 

330 """Classify a hostname as local (True) / public (False) / unknown (None). 

331 

332 Uses the run-scoped DNS cache to avoid repeated ``getaddrinfo`` lookups. 

333 Falls back to public on DNS timeout (fail-safe). 

334 

335 Lock discipline: cache reads and writes are guarded by ``ctx._lock``; 

336 the DNS lookup itself runs OUTSIDE the lock so concurrent subagent 

337 threads don't serialize on each other's DNS calls. Cache writes are 

338 first-writer-wins (``_cache_classification``) so a hostname's 

339 classification is stable for the run even under concurrent disagreeing 

340 lookups. 

341 """ 

342 if not hostname: 342 ↛ 343line 342 didn't jump to line 343 because the condition on line 342 was never true

343 return None 

344 

345 # Cache hit — read under the lock so concurrent writers don't tear 

346 # the dict. 

347 with ctx._lock: 

348 if hostname in ctx._dns_cache: 

349 return ctx._dns_cache[hostname] 

350 

351 # User-declared local hostnames override DNS classification. 

352 if hostname in ctx.local_hostnames: 

353 return _cache_classification(ctx, hostname, True) 

354 

355 # NAT64-wrapped metadata IPs (e.g. 64:ff9b::169.254.169.254) classify 

356 # as link-local by is_private_ip but actually wrap AWS/GCE instance 

357 # metadata. Force them to PUBLIC so STRICT/PRIVATE_ONLY don't allow 

358 # them. Check happens BEFORE is_private_ip so the wrapping wins. 

359 if _is_nat64_wrapped_metadata(hostname): 

360 return _cache_classification(ctx, hostname, False) 

361 

362 # String-literal check first (no DNS needed for IPs and known literals). 

363 # If the helper itself raises (malformed hostname, broken stdlib edge case), 

364 # fall through to DNS resolution rather than fail the whole evaluation — 

365 # the DNS path has its own bounded timeout and error handling. 

366 from ..ssrf_validator import is_ip_blocked 

367 

368 try: 

369 # A literal cloud-metadata IP (169.254.169.254, fd00:ec2::254, …) passes 

370 # is_private_ip (link-local) but must NOT be treated as local — otherwise 

371 # STRICT/PRIVATE_ONLY would accept an IMDS SSRF target as a "local" 

372 # inference/search host. Mirror the DNS-branch metadata block below. 

373 # is_ip_blocked returns False for non-IP hostnames (ValueError → False), 

374 # so legitimate hostnames still fall through to is_private_ip / DNS. 

375 # block_link_local extends the same classification from the literal 

376 # metadata-address list to the whole IPv4 and IPv6 link-local ranges, 

377 # which must not be admitted under STRICT/PRIVATE_ONLY. 

378 # 

379 # Regression evidence and local-backend controls: 

380 # tests/security/test_llm_endpoint_link_local_hardening.py 

381 # - test_link_local_does_not_classify_as_local_in_egress_policy 

382 # - test_self_hosted_still_classifies_as_local 

383 # 

384 # Localhost and RFC1918 are untouched -- self-hosted backends live 

385 # there and must keep working. 

386 if is_ip_blocked( 

387 hostname, 

388 allow_localhost=True, 

389 allow_private_ips=True, 

390 block_link_local=True, 

391 ): 

392 return _cache_classification(ctx, hostname, False) 

393 if is_private_ip(hostname): 

394 return _cache_classification(ctx, hostname, True) 

395 except Exception as exc: 

396 logger.debug( 

397 "is_private_ip raised on hostname, falling back to DNS", 

398 error=str(exc), 

399 ) 

400 

401 # DNS-resolved classification — runs OUTSIDE the lock. 

402 if not allow_dns: 

403 # The caller (the advisory warning-banner render path) opted OUT of 

404 # the synchronous getaddrinfo so a settings-page render never blocks 

405 # up to _DNS_TIMEOUT_SEC on a lookup. Return "unknown" so adaptive 

406 # scope resolution falls back to the engine's static classification. 

407 # Deliberately NOT cached — a later enforcement-path call (allow_dns 

408 # default True) must still resolve this host for real. 

409 return None 

410 addr_info = _resolve_with_timeout(hostname) 

411 if addr_info is None: 

412 # Fail-safe: unresolvable / timeout → treat as public. 

413 return _cache_classification(ctx, hostname, False) 

414 

415 # A hostname is local only when every resolved address is local. A single 

416 # public or metadata answer can be selected by the HTTP client, so it must 

417 # prevent a local classification. 

418 if not addr_info: 418 ↛ 419line 418 didn't jump to line 419 because the condition on line 418 was never true

419 return _cache_classification(ctx, hostname, False) 

420 for entry in addr_info: 

421 try: 

422 ip_str = entry[4][0] 

423 # A hostname that resolves to a cloud-metadata IP must NOT be 

424 # treated as local: link-local metadata IPs (169.254.169.254, …) 

425 # pass is_private_ip, which would classify the host local and let 

426 # STRICT/PRIVATE_ONLY fetch it. Classify as public so those scopes 

427 # refuse it — mirrors the literal-IP metadata block in evaluate_url 

428 # and the NAT64 handling just below. (Under PUBLIC_ONLY/BOTH the 

429 # SSRF validator at the actual fetch is the metadata backstop.) 

430 if is_ip_blocked( 

431 ip_str, 

432 allow_localhost=True, 

433 allow_private_ips=True, 

434 block_link_local=True, 

435 ): 

436 return _cache_classification(ctx, hostname, False) 

437 if _is_nat64_wrapped_metadata(ip_str): 437 ↛ 438line 437 didn't jump to line 438 because the condition on line 437 was never true

438 return _cache_classification(ctx, hostname, False) 

439 if not is_private_ip(ip_str): 

440 return _cache_classification(ctx, hostname, False) 

441 except Exception: # pragma: no cover - defensive 

442 return _cache_classification(ctx, hostname, False) 

443 return _cache_classification(ctx, hostname, True) 

444 

445 

446def _get_engine_class(engine_name: str): 

447 """Lazy load the engine class from the registry to avoid circular imports.""" 

448 # Import is lazy to break the dependency cycle: 

449 # security/* → engines/* → security/* 

450 from ...web_search_engines.engine_registry import ENGINE_REGISTRY 

451 from ..module_whitelist import get_safe_module_class 

452 

453 entry = ENGINE_REGISTRY.get(engine_name) 

454 if entry is None: 

455 return None 

456 try: 

457 return get_safe_module_class(entry.module_path, entry.class_name) 

458 except Exception: 

459 return None 

460 

461 

462def _engine_flags(engine_cls): 

463 """Read the (is_public, is_local, url_setting) triple from an engine class. 

464 

465 Uses ``is True`` / ``is False`` semantics: a missing attribute is treated 

466 as "not declared", which is distinct from an attribute explicitly set 

467 to ``False``. 

468 """ 

469 is_public = getattr(engine_cls, "is_public", None) 

470 is_local = getattr(engine_cls, "is_local", None) 

471 url_setting = getattr(engine_cls, "url_setting", None) 

472 return is_public, is_local, url_setting 

473 

474 

475def _resolve_collection_is_public( 

476 engine_name: str, username: Optional[str] 

477) -> bool: 

478 """Look up a collection engine's per-collection ``is_public`` flag. 

479 

480 ``engine_name`` is ``"collection_<uuid>"`` or ``"library"``. The 

481 aggregate ``library`` is always private (it spans all local docs). 

482 Fails closed to private (False) on any lookup error or missing row — 

483 a collection we can't classify must NOT be treated as public. 

484 """ 

485 if engine_name == "library" or not engine_name.startswith("collection_"): 

486 return False 

487 collection_id = engine_name[len("collection_") :] 

488 try: 

489 from ...database.models.library import Collection 

490 from ...database.session_context import get_user_db_session 

491 

492 with get_user_db_session(username) as session: 

493 row = ( 

494 session.query(Collection) 

495 .filter(Collection.id == collection_id) 

496 .first() 

497 ) 

498 return bool(getattr(row, "is_public", False)) if row else False 

499 except Exception: # noqa: silent-exception - fail closed to private 

500 logger.debug( 

501 "could not resolve collection is_public; treating as private", 

502 engine=engine_name, 

503 ) 

504 return False 

505 

506 

507# --------------------------------------------------------------------------- 

508# Vector-store (RAG sink) classification 

509# --------------------------------------------------------------------------- 

510 

511# Provider keys known to keep every byte on this machine. This is a FALLBACK 

512# ONLY — the authoritative signal is the provider class's ``is_local_file`` 

513# flag (``vector_stores/base.py``); this set is consulted just when the class 

514# cannot be introspected (e.g. a partial install of an optional backend). 

515# Like the snapshot-less LLM allow-list below, keeping it tight is the point: 

516# a provider key that is not enumerated here and whose class cannot be loaded 

517# is treated as REMOTE, not local. 

518_LOCAL_FILE_VECTOR_STORE_PROVIDERS = frozenset({"faiss"}) 

519 

520# Prefixes a *network host* can never start with, so a vector-store URI that 

521# begins with one is unambiguously a local filesystem path (e.g. a Milvus-Lite 

522# style "./store.db"). Deliberately conservative: anything not matched here — a 

523# bare "store.db", a "host:port" — is NOT assumed to be a path and goes through 

524# the DNS classifier, which fails up to public when it cannot resolve. 

525# 

526# NOTE: a bare "/" is intentionally NOT in this tuple. A URI authority (the 

527# "//host[:port]" form used by e.g. a Milvus endpoint written without a 

528# scheme) also starts with "/", so treating a single leading "/" as 

529# sufficient would misclassify a network host as a local path. A genuine 

530# absolute path is handled separately by ``_is_local_filesystem_uri`` — a 

531# single leading "/" whose second character is NOT another "/". 

532_LOCAL_PATH_URI_PREFIXES = ("./", "../", "~/", "~\\", ".\\", "..\\") 

533 

534 

535def _vector_store_class(provider: str): 

536 """Load a vector-store provider's class, or ``None`` if unavailable. 

537 

538 Import is lazy and failure-tolerant: an optional backend whose driver is 

539 not installed (or an unknown provider key) must not break classification — 

540 the caller treats an un-introspectable non-default provider as remote. 

541 """ 

542 if not provider: 542 ↛ 543line 542 didn't jump to line 543 because the condition on line 542 was never true

543 return None 

544 try: 

545 from ...vector_stores.config import get_vector_store_class 

546 

547 return get_vector_store_class(provider) 

548 except Exception: 

549 logger.debug( 

550 "vector-store provider class unavailable; " 

551 "classifying from the provider key alone", 

552 provider=sanitize_for_log(provider), 

553 ) 

554 return None 

555 

556 

557def _normalize_provider_key(raw) -> str: 

558 """Normalize a provider key the way the vector-store factory does.""" 

559 if not isinstance(raw, str): 559 ↛ 560line 559 didn't jump to line 560 because the condition on line 559 was never true

560 return "" 

561 return raw.strip().strip("\"'").strip().lower() 

562 

563 

564def _resolve_index_vector_store_providers( 

565 engine_name: str, username: Optional[str] 

566) -> set: 

567 """Providers recorded on the CURRENT ``RAGIndex`` rows for this engine. 

568 

569 A collection is queried through the store its index was built in, which 

570 can differ from the currently configured default, so the recorded provider 

571 is an additional (tightening) signal on top of the settings-resolved one. 

572 ``library`` spans every collection, so every current row counts. 

573 

574 Fails SOFT (empty set) when the column does not exist (older schema), the 

575 DB is unavailable, or anything else goes wrong: the settings-resolved 

576 provider remains the primary signal, and the endpoint URI that decides 

577 exposure comes from settings regardless. 

578 """ 

579 try: 

580 from ...database.models.library import RAGIndex 

581 from ...database.session_context import get_user_db_session 

582 

583 column = getattr(RAGIndex, "vector_store_provider", None) 

584 if column is None: 

585 return set() # schema without a provider column: FAISS-only 

586 with get_user_db_session(username) as session: 

587 query = session.query(column).filter(RAGIndex.is_current.is_(True)) 

588 if engine_name != "library": 

589 query = query.filter(RAGIndex.collection_name == engine_name) 

590 return { 

591 key 

592 for key in (_normalize_provider_key(row[0]) for row in query) 

593 if key 

594 } 

595 except Exception: # noqa: silent-exception - additive signal only 

596 logger.debug( 

597 "could not resolve RAG index vector-store providers", 

598 engine=sanitize_for_log(engine_name), 

599 ) 

600 return set() 

601 

602 

603def _resolve_vector_store_providers( 

604 engine_name: str, ctx: EgressContext, settings_snapshot: Optional[dict] 

605) -> set: 

606 """Every vector-store provider a library/collection run could query. 

607 

608 The union of the settings-resolved default and the providers recorded on 

609 the engine's current index rows. An EMPTY set means the vector-store layer 

610 itself could not be reached — in which case the run cannot construct a 

611 store either, so nothing can leave the box and the caller keeps the 

612 (contained) status quo. 

613 """ 

614 providers = set() 

615 try: 

616 from ...vector_stores.config import resolve_provider 

617 

618 key = _normalize_provider_key(resolve_provider(settings_snapshot)) 

619 if key: 619 ↛ 623line 619 didn't jump to line 623 because the condition on line 619 was always true

620 providers.add(key) 

621 except Exception: # noqa: silent-exception - see docstring 

622 logger.debug("vector-store provider resolution unavailable") 

623 providers |= _resolve_index_vector_store_providers( 

624 engine_name, getattr(ctx, "username", None) 

625 ) 

626 return providers 

627 

628 

629def _starts_with_double_separator(s: str) -> bool: 

630 """True when ``s`` opens with two path-separator characters. 

631 

632 Covers all four combinations of "/" and "\\" — "//", "/\\", "\\/", and 

633 "\\\\" — since ``ntpath.normpath`` treats any of them as the start of a 

634 UNC network-share prefix on Windows (e.g. ``ntpath.normpath("/\\host/x") 

635 == "\\\\host\\x"``). A forward-slash-only check lets the mixed-slash 

636 forms smuggle a remote share past the "unambiguously local" classifier. 

637 """ 

638 return len(s) >= 2 and s[0] in "/\\" and s[1] in "/\\" 

639 

640 

641# ``urllib.parse.urlsplit`` does NOT parse the string it is handed verbatim. 

642# Following the WHATWG URL parser it first lstrips every C0 control character 

643# and space, then DELETES every tab, CR and LF from ANYWHERE in the URL 

644# (CPython's ``_WHATWG_C0_CONTROL_OR_SPACE`` and 

645# ``_UNSAFE_URL_BYTES_TO_REMOVE``). ``str.strip()`` does neither: it leaves 

646# "\x00" and the other non-whitespace C0 controls in place, and never touches 

647# a tab in the MIDDLE of a string. 

648_URL_PARSER_LEADING_STRIP = "".join(chr(code) for code in range(0x20)) + " " 

649_URL_PARSER_REMOVED_CHARS = ("\t", "\r", "\n") 

650 

651 

652def _normalize_uri_like_url_parser(value: str) -> str: 

653 """``value`` reduced to the string ``urlsplit`` would actually parse. 

654 

655 Exists for the RAW string tests in ``_is_local_filesystem_uri`` 

656 (``startswith``, ``_starts_with_double_separator``, a drive-letter index), 

657 whose verdict is consumed side by side with ``_classify_engine_url``, 

658 which reads the host out of ``urlsplit``. Judging the raw string opens a 

659 parser differential in which one value is simultaneously "a local 

660 filesystem path" to ``str.startswith`` and "an authority" to every real 

661 URL parser: 

662 

663 * ``"x:/\\t/attacker.example.com:19530"`` looks drive-absolute to a 

664 literal ``"//"`` test (``uri[2:]`` is ``"/\\t/..."``), while 

665 ``urlsplit`` deletes the tab and reports netloc 

666 ``attacker.example.com:19530``. 

667 * ``"/\\t/attacker.example.com/share/db"`` looks like an ordinary 

668 single-slash absolute path to ``_starts_with_double_separator``, while 

669 ``urlsplit`` deletes the tab and reports the same authority. 

670 * A leading ``"\\x00"`` (or any other non-whitespace C0 control) survives 

671 ``str.strip()`` and defeats every raw prefix test, while ``urlsplit`` 

672 lstrips it away. 

673 

674 Normalising once, here, is what keeps those predicates and ``urlsplit`` 

675 from drifting apart again. 

676 

677 A raw-string test is not the only caller that needs this. The final 

678 ``strip()`` is BIDIRECTIONAL, and its LEADING half removes 19 

679 Unicode-whitespace codepoints that ``urlsplit``'s WHATWG lstrip 

680 (``\\x00``-``\\x20`` only) leaves in place: U+0085, U+00A0, U+1680, 

681 U+2000-U+200A, U+2028, U+2029, U+202F, U+205F and U+3000. A leading one 

682 of those makes ``urlsplit`` report an EMPTY scheme, so a function that 

683 asks the parser for the scheme does NOT already get this preprocessing 

684 for free. The call is therefore load-bearing in 

685 ``_file_uri_names_a_remote_share``: without it, 

686 "\\xa0file://localhost//attacker.example.com/share/db" has scheme "" and 

687 is not a ``file://`` URI to that function, the network-share rejection 

688 never fires, and the entry falls through to hostname classification — 

689 where a sibling entry such as "127.0.0.1" answers LOCAL and the whole 

690 setting classifies contained. Never drop this call on the ground that 

691 ``urlsplit`` repeats it; for the leading-whitespace half it does not. 

692 

693 Only the TRAILING half of that ``strip()`` is a pure caller-compatibility 

694 trim: ``urlsplit`` keeps trailing whitespace where this does not, but 

695 trailing whitespace can never turn a local path into an authority, so the 

696 extra trim cannot make a remote endpoint look local. 

697 """ 

698 normalized = value.lstrip(_URL_PARSER_LEADING_STRIP) 

699 for char in _URL_PARSER_REMOVED_CHARS: 

700 normalized = normalized.replace(char, "") 

701 return normalized.strip() 

702 

703 

704def _is_local_filesystem_uri(value) -> bool: 

705 """True when a vector-store URI is unambiguously a local file path.""" 

706 if not isinstance(value, str): 

707 return False 

708 # Judge the string ``urlsplit`` would parse, never the raw one — see 

709 # ``_normalize_uri_like_url_parser``. Every prefix, separator and 

710 # drive-letter test below is a RAW string test, so without this the 

711 # "local drive path" / "single-slash absolute path" verdicts diverge from 

712 # the host ``_classify_engine_url`` goes on to classify. This call is what 

713 # stops "x:/\t/attacker.example.com:19530" and 

714 # "/\t/attacker.example.com/share/db" short-circuiting LOCAL. 

715 uri = _normalize_uri_like_url_parser(value) 

716 if not uri: 

717 return False 

718 if uri.lower().startswith("file://"): 

719 # RFC 8089 local forms: "file:///path" (empty authority), 

720 # "file://localhost/path", or the Windows drive-letter form some 

721 # resolvers write as "file://C:/data/store.db" — urlsplit reads the 

722 # drive letter + colon as the netloc there (there is no real 

723 # authority, just a colon that looks like one). Any OTHER authority 

724 # is a remote-share reference some resolvers treat as a host (e.g. 

725 # "file://attacker.example.com/share/store.db") and must NOT be 

726 # classified as on-box — fall through to the remote/DNS path. 

727 # 

728 # ``urlsplit`` is NOT exception-safe: a malformed authority (e.g. an 

729 # unbalanced IPv6-literal bracket, "file://[attacker.example.com/x" 

730 # or "file://[::1/x") raises ``ValueError: Invalid IPv6 URL`` instead 

731 # of returning a parse. Unlike its sibling in ``_classify_engine_url`` 

732 # (which already wraps this identical call), this call was bare, so 

733 # the exception used to propagate all the way up through 

734 # ``_vector_store_endpoint_is_local`` -> ``vector_store_is_contained`` 

735 # -> ``classify_engine`` into ``_resolve_adaptive_scope``'s catch-all, 

736 # which resolves the PERMISSIVE ``EgressScope.BOTH`` — exactly the 

737 # cloud-capable scope this module's fail-up rules exist to avoid. 

738 # Fail toward not-local instead, matching every other unknown here. 

739 try: 

740 parsed = urlsplit(uri) 

741 except Exception: 

742 return False 

743 netloc = parsed.netloc 

744 # Windows drive letters are ASCII A-Z only. ``str.isalpha()`` is 

745 # Unicode-wide (Cyrillic "с", fullwidth "C", accented "é" all 

746 # match), so a Unicode lookalike authority (e.g. 

747 # "file://с:/share/db") must NOT pass as an on-box drive path — 

748 # the same hazard class as the single-letter-scheme lookalikes at 

749 # the bottom of this function. 

750 is_drive_letter_netloc = ( 

751 len(netloc) == 2 

752 and netloc[0].isascii() 

753 and netloc[0].isalpha() 

754 and netloc[1] == ":" 

755 ) 

756 if not ( 

757 netloc == "" 

758 or netloc.lower() == "localhost" 

759 or is_drive_letter_netloc 

760 ): 

761 return False 

762 # A 4-plus-slash form ("file:////attacker.example.com/share/db") 

763 # parses with an EMPTY netloc too — urlsplit only ever gives the 

764 # first two slashes after the scheme authority meaning, so a third 

765 # and fourth slash just start the PATH — but that path then begins 

766 # with "//host/share", a UNC-style remote-share reference smuggled 

767 # past the empty-netloc check above. Reject that form: a local file 

768 # path never legitimately starts with two slashes. A mixed-slash 

769 # variant ("file:///\\host/share/db" -> path "/\\host/share/db") 

770 # smuggles the same UNC authority past a forward-slash-only check: 

771 # ``ntpath.normpath`` treats ANY pair of leading separators — "//", 

772 # "/\\", "\\/", or "\\\\" — as a UNC prefix on Windows, so reject all 

773 # four combinations, not just the literal "//" one. The path may be 

774 # percent-encoded (RFC 8089), so DECODE it before the separator 

775 # check: "file:///%2fattacker.example.com/share/db" and the "%5c" 

776 # twin read as single-separator paths while encoded, but unquote 

777 # to exactly the "//host" / "/\\host" UNC prefixes the check exists 

778 # to catch (``ntpath.normpath`` resolves both to a remote share). 

779 # Decoding cannot smuggle an authority in: netloc was already 

780 # validated above, and plain local paths ("/var/lib/x", 

781 # "/C:/data") keep their single leading separator through unquote. 

782 # 

783 # DEFENCE IN DEPTH: this decode-and-check is a deliberate duplicate 

784 # of ``_file_uri_names_a_remote_share``, which 

785 # ``_vector_store_endpoint_is_local`` — the ONLY production caller of 

786 # this function — already ran one frame earlier over every entry of 

787 # the setting. At every production entry point the earlier check has 

788 # therefore already returned "not contained", so reverting the 

789 # ``unquote`` here changes no end-to-end outcome; it stays because 

790 # this function is a general-purpose predicate that a future caller 

791 # may reach directly, and it is pinned by its own unit test 

792 # (``test_file_uri_percent_encoded_unc_in_path_is_not_a_local_file``). 

793 return not _starts_with_double_separator(unquote(parsed.path)) 

794 if uri.startswith(_LOCAL_PATH_URI_PREFIXES): 

795 return True 

796 # A single leading "/" is a local absolute path ("/var/lib/x.db") ONLY 

797 # when it is not the start of a "//host[:port]" URI authority (e.g. a 

798 # Milvus endpoint written as "//attacker.example.com:19530"), including 

799 # the mixed-slash smuggling form "/\\host/share" that ``ntpath.normpath`` 

800 # also resolves to a UNC network share on Windows. Reject any such 

801 # authority-ish form here so it falls through to the DNS classifier 

802 # instead of being silently treated as on-box. 

803 if uri.startswith("/") and not _starts_with_double_separator(uri): 

804 return True 

805 # Windows drive-absolute path ("C:\\data\\store.db"). What follows the 

806 # drive letter must not be a literal "//" — "<letter>://" is a 

807 # "scheme://host" URL authority marker, not a path, so e.g. 

808 # "x://attacker.example.com:19530" must NOT be misread as a local drive 

809 # path and skip the DNS classifier. Note ``str.isalpha()`` is 

810 # Unicode-wide (Cyrillic/fullwidth letters match too, e.g. "с://host" or 

811 # "C://host"), so the drive letter must be ASCII here for the same 

812 # reason the ``file://`` netloc test above requires it: "с:/x", "C:/x" 

813 # and "é:/x" are not Windows drive-absolute paths, and short-circuiting 

814 # them to on-box would skip the DNS classifier on a value no resolver 

815 # reads as a local drive. The literal-"//" authority check below is a 

816 # separate, independent guard covering the "<letter>://host" form. 

817 # 

818 # ONLY the literal "//" form is rejected, not every doubled separator: 

819 # a drive letter always wins over a UNC prefix on Windows 

820 # (``ntpath.normpath("C:\\\\data") == "C:\\data"``, never a network 

821 # share), and — ON THE NORMALISED STRING this function tests — 

822 # ``urlsplit`` gives an authority only to a literal "//", so 

823 # "C:\\\\data\\store.db", "C:/\\data" and "C:\\/data" cannot name a 

824 # remote host under either reading and are genuine local paths. That 

825 # equivalence holds ONLY because ``_normalize_uri_like_url_parser`` has 

826 # already deleted the tab/CR/LF and leading C0 controls that ``urlsplit`` 

827 # itself deletes: on the RAW string "x:/\t/attacker.example.com:19530" 

828 # this literal-"//" test answers "no authority, local drive path" while 

829 # ``urlsplit`` answers "netloc=attacker.example.com:19530". Never move 

830 # this test back onto an un-normalised string. The 

831 # forward-slash form "C://data" is irreducibly ambiguous — it parses as 

832 # scheme "c" with hostname "data" — so it stays rejected and falls 

833 # through to the DNS classifier; see changelog.d/5763.security.md. 

834 return ( 

835 len(uri) > 2 

836 and uri[0].isascii() 

837 and uri[0].isalpha() 

838 and uri[1] == ":" 

839 and uri[2] in "\\/" 

840 and not uri[2:].startswith("//") 

841 ) 

842 

843 

844def _file_uri_names_a_remote_share(value) -> bool: 

845 """True when ``value`` is a ``file://`` URI that does not stay on the box. 

846 

847 The DECODED path of a ``file://`` URI can name a UNC network share 

848 (``//host/share``, or any of the mixed "/"/"\\" separator pairs 

849 ``ntpath.normpath`` also resolves to one) regardless of what the URI's 

850 nominal authority says — "file://localhost//attacker.example.com/share" 

851 reads as localhost but resolves off-box. Such a URI must stay 

852 non-contained even after ``_is_local_filesystem_uri`` has declined it, 

853 so hostname classification cannot rescue it via the local-looking 

854 authority. 

855 

856 The "is this a ``file://`` URI?" question is answered by the PARSED 

857 scheme, not by a raw ``startswith`` on the string: ``urlsplit`` deletes 

858 tab/CR/LF from anywhere in a URL and lstrips C0 controls, so 

859 "fi\\tle://localhost//attacker.example.com/share/db" is not a ``file://`` 

860 URI to ``startswith`` but IS one to every real URL parser — and 

861 ``_classify_engine_url``, the frame that decides the host, is a real URL 

862 parser, which for that value reports the local-looking authority 

863 ``localhost`` and would classify the endpoint contained. Asking 

864 ``urlsplit`` for the scheme is what keeps this function from being 

865 stepped around by such a value. 

866 

867 ``value`` is normalised by ``_normalize_uri_like_url_parser`` BEFORE it 

868 reaches ``urlsplit``, and that call is load-bearing here, not a duplicate 

869 of the parser's own preprocessing. ``urlsplit`` lstrips only 

870 ``\\x00``-``\\x20``, while the normaliser's final ``strip()`` is 

871 bidirectional and additionally removes the 19 leading Unicode-whitespace 

872 codepoints ``str.strip()`` knows and the parser does not (U+0085, U+00A0, 

873 U+1680, U+2000-U+200A, U+2028, U+2029, U+202F, U+205F, U+3000). Handed 

874 the RAW value, "\\xa0file://localhost//attacker.example.com/share/db" 

875 parses with an EMPTY scheme, this function answers False, the 

876 network-share rejection never fires, and — inside a list-shaped endpoint 

877 setting whose sibling entry is "127.0.0.1" — the whole setting classifies 

878 CONTAINED. Normalise first; see 

879 ``test_leading_unicode_whitespace_cannot_hide_a_network_share``. 

880 

881 Fails toward "remote" for a malformed authority: ``urlsplit`` raises 

882 (``ValueError``: "Invalid IPv6 URL") on an unbalanced IPv6 bracket rather 

883 than parsing, and an endpoint we cannot parse is one we cannot prove 

884 local. The catch is broad, matching its sibling in 

885 ``_is_local_filesystem_uri``, so a future parser error class cannot turn 

886 a fail-closed answer into an exception that escapes into 

887 ``_resolve_adaptive_scope``'s permissive catch-all. 

888 

889 Non-strings answer False here — they are not ``file://`` URIs, and every 

890 other non-string shape already fails closed downstream by never proving 

891 itself local. 

892 """ 

893 if not isinstance(value, str): 

894 return False 

895 uri = _normalize_uri_like_url_parser(value) 

896 try: 

897 parsed = urlsplit(uri) 

898 if parsed.scheme.lower() != "file": 

899 return False 

900 return _starts_with_double_separator(unquote(parsed.path)) 

901 except Exception: 

902 return True 

903 

904 

905def _vector_store_endpoint_is_local( 

906 provider: str, 

907 settings_snapshot: Optional[dict], 

908 ctx: EgressContext, 

909 allow_dns: bool = True, 

910) -> bool: 

911 """Whether a non-local-file store's configured endpoint stays on the box. 

912 

913 Mirrors the engine ``url_setting`` fail-up: the provider class names the 

914 settings key holding its endpoint (``uri_setting``), that value is 

915 classified by DNS, and ONLY a positively-local answer counts as contained. 

916 A provider that declares no key, an unset/unparseable URI, or a host that 

917 resolves publicly all yield False — for a store that is not a local file, 

918 an endpoint we cannot prove local must be assumed to be off-machine. 

919 """ 

920 cls = _vector_store_class(provider) 

921 uri_setting = getattr(cls, "uri_setting", None) if cls else None 

922 if not uri_setting: 

923 return False 

924 uri = _get_setting_value(settings_snapshot or {}, uri_setting, None) 

925 # Reject a network-share file path PER ENTRY. A setting value is not 

926 # necessarily a string: ``unwrap_setting`` peels the ``{"value": ...}`` 

927 # snapshot wrapper and returns whatever it wrapped, uncoerced, and 

928 # ``_classify_engine_url`` below explicitly supports the LIST shape 

929 # (Elasticsearch-style endpoint lists). A string-only guard here would 

930 # leave a one-element list to fall through to hostname classification, 

931 # where a nominally local authority ("localhost", "127.0.0.1") answers 

932 # True and overrules the path rejection. Normalising to entries first 

933 # keeps a single rejection site that covers every shape the setting can 

934 # hold, and fails the WHOLE setting closed when ANY entry is a rejected 

935 # path — matching the "any public entry wins" fail-up 

936 # ``_classify_engine_url`` already applies to list-typed endpoints. 

937 # 

938 # COUPLED WITH ``_classify_engine_url``: this loop and that function must 

939 # keep the SAME container test (``isinstance(..., list)``, not 

940 # ``(list, tuple)``) and the same "skip non-``str`` entries" rule. If one 

941 # side alone grows tuple support, a tuple-shaped setting reaches hostname 

942 # classification with a rejected network-share path still in it — the 

943 # exact bypass this per-entry loop exists to close. Change both or 

944 # neither. 

945 for entry in uri if isinstance(uri, list) else (uri,): 

946 if _file_uri_names_a_remote_share(entry): 

947 return False 

948 if _is_local_filesystem_uri(uri): 

949 return True 

950 return ( 

951 _classify_engine_url( 

952 uri_setting, settings_snapshot or {}, ctx, allow_dns=allow_dns 

953 ) 

954 is True 

955 ) 

956 

957 

958def vector_store_is_contained( 

959 engine_name: str, 

960 ctx: EgressContext, 

961 settings_snapshot: Optional[dict], 

962 allow_dns: bool = True, 

963) -> bool: 

964 """Whether the RAG vector store(s) behind ``engine_name`` stay on the box. 

965 

966 A library/collection engine is only as contained as the vector store its 

967 embeddings are written to and searched in. A local-file store (FAISS) is 

968 contained by construction — ``is_local_file`` is the discriminator, so 

969 this is a strict no-op for it and no DNS lookup happens. A server-backed 

970 store is contained only while its configured endpoint resolves local; 

971 otherwise the collection's embeddings (and every query) leave the machine 

972 and the engine must fail UP, exactly as Paperless/Elasticsearch do via 

973 ``url_setting``. 

974 

975 Single home for the rule so the scope PEP (:func:`classify_engine`) and 

976 the two-axis resolver (``run_classification.engine_label``) can never 

977 diverge. 

978 """ 

979 for provider in _resolve_vector_store_providers( 

980 engine_name, ctx, settings_snapshot 

981 ): 

982 cls = _vector_store_class(provider) 

983 if cls is not None: 

984 if getattr(cls, "is_local_file", False) is True: 

985 continue 

986 elif provider in _LOCAL_FILE_VECTOR_STORE_PROVIDERS: 

987 continue 

988 if not _vector_store_endpoint_is_local( 

989 provider, settings_snapshot, ctx, allow_dns=allow_dns 

990 ): 

991 return False 

992 return True 

993 

994 

995def classify_engine( 

996 engine_name: str, 

997 ctx: EgressContext, 

998 *, 

999 settings_snapshot: dict, 

1000 metadata: Optional[dict] = None, 

1001 allow_dns: bool = True, 

1002) -> EngineClassification: 

1003 """Classify an engine from its flags, collection metadata, and URL policy. 

1004 

1005 The class-level ``is_public`` / ``is_local`` flags express the engine's 

1006 *nature* — whether it queries the public internet or searches local data — 

1007 regardless of where the engine's server happens to be hosted. This is used 

1008 by ``evaluate_engine`` and ``_resolve_adaptive_scope`` so that e.g. a 

1009 locally-hosted SearXNG (which proxies to Google/Bing) is still treated as 

1010 a public engine. 

1011 

1012 The URL override is asymmetric and can only make the classification 

1013 MORE restrictive: a local-nature engine whose ``url_setting`` resolves 

1014 to a public host is reclassified public (queries would leave the box), 

1015 while a public-nature engine on a local URL stays public. 

1016 

1017 - Static engines: read declared ``is_public``/``is_local`` flags directly. 

1018 - ``library`` / ``collection_<uuid>``: per-collection ``is_public`` 

1019 flag (from ``metadata`` when the caller has the engine config, else a 

1020 direct DB lookup), defaulting private. 

1021 - Anything else (unknown): ``EngineClassification(None, None)``. 

1022 """ 

1023 engine_cls = _get_engine_class(engine_name) 

1024 if engine_cls is None: 

1025 if engine_name == "library" or engine_name.startswith("collection_"): 

1026 if metadata is not None and metadata.get("is_public") is not None: 

1027 is_public = bool(metadata.get("is_public")) 

1028 else: 

1029 is_public = _resolve_collection_is_public( 

1030 engine_name, ctx.username 

1031 ) 

1032 # A collection is ALWAYS a local knowledge base — it lives on this 

1033 # machine — so it stays searchable under PRIVATE_ONLY regardless of 

1034 # its public flag. ``is_public`` is ADDITIVE, not exclusive: marking 

1035 # a collection public (non-sensitive content — papers you fetched, 

1036 # public publications) ALSO makes it eligible under PUBLIC_ONLY and 

1037 # OK to process with cloud inference. A private collection is 

1038 # local-only and excluded from public-scope runs. Hence 

1039 # ``(is_public, is_local=True)`` rather than the old mutually- 

1040 # exclusive ``(is_public, not is_public)`` which wrongly hid a 

1041 # public collection from private runs. 

1042 # 

1043 # ... UNLESS the vector store behind it is not on this machine. 

1044 # A collection is only local while its index is: a server-backed 

1045 # store on a public endpoint ships the collection's embeddings and 

1046 # every query to a third party, so it fails UP — but unlike the 

1047 # Elasticsearch/Paperless case (a nature-private engine that 

1048 # becomes nature-public once its endpoint leaves the box), a 

1049 # collection's ``is_public`` is an independent, already-resolved 

1050 # signal about the *content*, not a byproduct of containment. 

1051 # Losing containment must only ever ADD restriction, never grant 

1052 # new eligibility: it strips ``is_local`` (PRIVATE_ONLY denies it, 

1053 # data no longer stays on the box) while PRESERVING the real 

1054 # ``is_public`` flag (a private-flagged collection does NOT 

1055 # become newly eligible under PUBLIC_ONLY just because its store 

1056 # is remote — that would be a relaxation, not a tightening). 

1057 # Strict no-op for a local-file store (FAISS): 

1058 # ``vector_store_is_contained`` returns True without touching DNS. 

1059 if not vector_store_is_contained( 

1060 engine_name, ctx, settings_snapshot, allow_dns=allow_dns 

1061 ): 

1062 return EngineClassification(is_public=is_public, is_local=False) 

1063 return EngineClassification(is_public=is_public, is_local=True) 

1064 return EngineClassification(is_public=None, is_local=None) 

1065 is_public, is_local, url_setting = _engine_flags(engine_cls) 

1066 # Fail-up URL override (asymmetric, only ever MORE restrictive): a 

1067 # local-data engine (Elasticsearch, Paperless) whose configured URL 

1068 # resolves to a PUBLIC host is reclassified as public — querying it 

1069 # sends the user's queries off the box, so PRIVATE_ONLY must deny it 

1070 # at selection time (matching pre-static-flags behavior). The reverse 

1071 # direction is deliberately NOT applied: a public-nature engine 

1072 # (SearXNG) hosted on localhost stays public, because its data source 

1073 # is the internet regardless of where the proxy runs. 

1074 if url_setting and is_local is True and is_public is not True: 

1075 host_classification = _classify_engine_url( 

1076 url_setting, settings_snapshot, ctx, allow_dns=allow_dns 

1077 ) 

1078 if host_classification is False: 

1079 # False from _classify_engine_url == "connect target is a 

1080 # PUBLIC host" -> fail up to public. 

1081 return EngineClassification(is_public=True, is_local=False) 

1082 return EngineClassification(is_public=is_public, is_local=is_local) 

1083 

1084 

1085# Denial reasons that justify dropping an engine from an LLM candidate 

1086# list. Only ACTIVE policy denials qualify — see filter_engines_by_egress. 

1087_PREFILTER_STRIP_REASONS = frozenset( 

1088 { 

1089 "scope_mismatch_public_only", 

1090 "scope_mismatch_private_only", 

1091 "strict_not_primary", 

1092 # Registered engine without classification flags: the factory's 

1093 # evaluate_engine call fails it closed too, so stripping matches 

1094 # enforcement exactly. 

1095 "unclassified", 

1096 } 

1097) 

1098 

1099 

1100def filter_engines_by_egress( 

1101 engine_names: list[str], 

1102 ctx: EgressContext, 

1103 settings_snapshot: dict, 

1104) -> list[str]: 

1105 """ADVISORY pre-filter for a candidate engine list: remove engines the 

1106 egress scope actively denies so the LLM doesn't waste selection slots 

1107 on them. Uses the engine's static class flags (``is_public`` / 

1108 ``is_local``) — a locally-hosted SearXNG is still treated as public 

1109 because it queries the internet. 

1110 

1111 This is a UX optimization in FRONT of the factory PEP, not an 

1112 enforcement point — so it must never be STRICTER than the factory. 

1113 Names unknown to the static registry (``engine_unknown``) are KEPT 

1114 (except under STRICT, where the not-the-primary gate fires before the 

1115 registry lookup — exactly as it does in the factory): 

1116 they may be retriever-backed or dynamically injected engines that the 

1117 factory evaluates via its own retriever path; dropping them here would 

1118 silently hide legitimate engines from LLM selection. The factory 

1119 still denies anything truly disallowed at instantiation. 

1120 

1121 Callers: the LangGraph tool builder, or any code that builds a 

1122 selection set before presenting it to an LLM. 

1123 """ 

1124 allowed: list[str] = [] 

1125 for name in engine_names: 

1126 decision = evaluate_engine( 

1127 name, ctx, settings_snapshot=settings_snapshot 

1128 ) 

1129 if decision.allowed or decision.reason not in _PREFILTER_STRIP_REASONS: 

1130 allowed.append(name) 

1131 return allowed 

1132 

1133 

1134def filter_candidates_by_egress( 

1135 engine_names: list[str], 

1136 settings_snapshot: Optional[dict], 

1137) -> list[str]: 

1138 """Best-effort scope pre-filter for an LLM candidate list. 

1139 

1140 Wraps ``filter_engines_by_egress`` with the snapshot plumbing 

1141 LLM-selection callers share: read ``policy.egress_scope`` / 

1142 ``search.tool`` from the snapshot (flat or ``{"value": ...}`` shaped), 

1143 build the context, filter. Returns the input list unchanged when the 

1144 snapshot is missing, the scope is BOTH (nothing to strip), or anything 

1145 fails — the factory PEP still enforces at child instantiation, so this 

1146 helper must never break engine selection. 

1147 """ 

1148 if not settings_snapshot: 

1149 return engine_names 

1150 try: 

1151 scope_raw = str( 

1152 _get_setting_value( 

1153 settings_snapshot, "policy.egress_scope", DEFAULT_EGRESS_SCOPE 

1154 ) 

1155 or DEFAULT_EGRESS_SCOPE 

1156 ).lower() 

1157 # BOTH (or an unrecognized value) strips nothing at selection time; 

1158 # adaptive is included because it can RESOLVE to a restrictive scope. 

1159 if scope_raw not in ( 

1160 "private_only", 

1161 "public_only", 

1162 "strict", 

1163 "adaptive", 

1164 ): 

1165 return engine_names 

1166 primary = resolve_run_primary_engine(settings_snapshot) 

1167 ctx = context_from_snapshot( 

1168 settings_snapshot, 

1169 primary, 

1170 username=settings_snapshot.get("_username"), 

1171 ) 

1172 return filter_engines_by_egress(engine_names, ctx, settings_snapshot) 

1173 except Exception: # noqa: silent-exception - advisory pre-filter only 

1174 logger.bind(policy_audit=True).debug( 

1175 "egress candidate pre-filter failed; " 

1176 "factory PEP still enforces at child instantiation" 

1177 ) 

1178 return engine_names 

1179 

1180 

1181def evaluate_engine( 

1182 engine_name: str, 

1183 ctx: EgressContext, 

1184 *, 

1185 settings_snapshot: dict, 

1186 metadata: Optional[dict] = None, 

1187) -> Decision: 

1188 """Decide whether an engine may be instantiated under the current policy. 

1189 

1190 Allows iff (a) the engine's classification is compatible with the scope's 

1191 bucket AND (b) if scope == STRICT, the engine name equals the primary. 

1192 Unclassified engines always fail closed. 

1193 

1194 ``metadata`` (optional) carries the engine's config entry (e.g. the 

1195 per-collection ``is_public`` from search_config) so callers that already 

1196 have it avoid a redundant DB lookup; when absent it's resolved directly. 

1197 """ 

1198 if settings_snapshot is None: 

1199 return Decision(False, "no_snapshot") 

1200 try: 

1201 # UNPROTECTED: egress protection disabled for this run (escape hatch) 

1202 # — any engine is permitted. SSRF/metadata blocks still apply at 

1203 # evaluate_url; this only lifts the egress-scope engine gate. 

1204 if ctx.scope == EgressScope.UNPROTECTED: 

1205 return Decision(True, "egress_unprotected") 

1206 

1207 # STRICT: only the primary engine is permitted. 

1208 if ( 

1209 ctx.scope == EgressScope.STRICT 

1210 and engine_name != ctx.primary_engine 

1211 ): 

1212 return Decision(False, "strict_not_primary") 

1213 

1214 # A truly unknown engine (not in the static registry and not a 

1215 # collection) fails closed as engine_unknown. ``library`` / 

1216 # ``collection_*`` are classified by classify_engine below. 

1217 is_collection = engine_name == "library" or engine_name.startswith( 

1218 "collection_" 

1219 ) 

1220 if _get_engine_class(engine_name) is None and not is_collection: 

1221 return Decision(False, "engine_unknown") 

1222 

1223 classification = classify_engine( 

1224 engine_name, 

1225 ctx, 

1226 settings_snapshot=settings_snapshot, 

1227 metadata=metadata, 

1228 ) 

1229 

1230 # Fail-closed for unclassified engines. 

1231 if ( 

1232 classification.is_public is not True 

1233 and classification.is_local is not True 

1234 ): 

1235 return Decision(False, "unclassified") 

1236 

1237 # Scope/bucket compatibility. 

1238 if ( 

1239 ctx.scope == EgressScope.PUBLIC_ONLY 

1240 and classification.is_public is not True 

1241 ): 

1242 return Decision(False, "scope_mismatch_public_only") 

1243 if ( 

1244 ctx.scope == EgressScope.PRIVATE_ONLY 

1245 and classification.is_local is not True 

1246 ): 

1247 return Decision(False, "scope_mismatch_private_only") 

1248 

1249 return Decision(True, "allowed") 

1250 except Exception: # pragma: no cover - defensive 

1251 logger.bind(policy_audit=True).exception( 

1252 "evaluate_engine internal error", engine=engine_name 

1253 ) 

1254 return Decision(False, "internal_error") 

1255 

1256 

1257def _classify_engine_url( 

1258 url_setting: str, 

1259 settings_snapshot: dict, 

1260 ctx: EgressContext, 

1261 allow_dns: bool = True, 

1262) -> Optional[bool]: 

1263 """Classify an engine's configured URL via DNS resolution. 

1264 

1265 For list-typed settings (Elasticsearch ``hosts``), returns False (public) 

1266 if ANY entry classifies as public — safer fail-up. 

1267 Returns True if local, False if public, None if undetermined. 

1268 """ 

1269 value = _get_setting_value(settings_snapshot, url_setting, None) 

1270 if not value: 

1271 return None 

1272 

1273 # COUPLED WITH the per-entry network-share rejection in 

1274 # ``_vector_store_endpoint_is_local``: both must keep the SAME container 

1275 # test (``isinstance(..., list)``, not ``(list, tuple)``) and the same 

1276 # "skip non-``str`` entries" rule. Widening only one side lets a 

1277 # tuple-shaped setting reach this classifier with an entry the other side 

1278 # never examined. Change both or neither. 

1279 entries = value if isinstance(value, list) else [value] 

1280 any_public = False 

1281 any_local = False 

1282 for entry in entries: 

1283 if not isinstance(entry, str): 1283 ↛ 1284line 1283 didn't jump to line 1284 because the condition on line 1283 was never true

1284 continue 

1285 try: 

1286 if "://" in entry: 

1287 to_parse = entry 

1288 elif entry.startswith("//"): 

1289 # Scheme-less "//host[:port]" authority form (e.g. a Milvus 

1290 # endpoint written without a scheme). Prepending "http://" 

1291 # here would yield "http:////host:port", which urlsplit 

1292 # mis-parses as an empty netloc with the host landing in 

1293 # ``path`` — silently dropping the hostname from 

1294 # classification. Prepend only the scheme so the leading 

1295 # "//" the entry already supplies becomes the URL's 

1296 # authority marker. 

1297 to_parse = f"http:{entry}" 

1298 else: 

1299 to_parse = f"http://{entry}" 

1300 parsed = urlsplit(to_parse) 

1301 hostname = parsed.hostname 

1302 except Exception: 

1303 continue 

1304 if not hostname: 

1305 continue 

1306 # Decode percent-encoding before classifying, identically to 

1307 # evaluate_url: the HTTP client decodes the host before connecting, 

1308 # so "192%2e168%2e1%2e1" must be classified as 192.168.1.1, not as 

1309 # an unresolvable literal. 

1310 hostname = unquote(hostname) 

1311 result = _classify_host(hostname, ctx, allow_dns=allow_dns) 

1312 if result is True: 

1313 any_local = True 

1314 elif result is False: 

1315 any_public = True 

1316 

1317 if any_public: 

1318 return False # "any public" wins under list-typed settings 

1319 if any_local: 

1320 return True 

1321 return None 

1322 

1323 

1324_CLOUD_LLM_PROVIDERS = frozenset( 

1325 { 

1326 "openai", 

1327 "anthropic", 

1328 "google", 

1329 "openrouter", 

1330 "requesty", 

1331 "orcarouter", 

1332 "atlascloud", 

1333 "deepseek", 

1334 "xai", 

1335 "ionos", 

1336 } 

1337) 

1338 

1339# Providers that default to a localhost endpoint and ship without any 

1340# remote routing. The LLM gate at ``config/llm_config.py`` uses this set 

1341# as a SNAPSHOT-LESS ALLOW-LIST: when ``get_llm`` is invoked with no 

1342# settings snapshot (background helpers, scaffolding paths, tests), only 

1343# these providers may proceed; everything else — including ambiguous 

1344# providers like ``openai_endpoint`` and any future cloud provider not 

1345# yet enumerated in ``_CLOUD_LLM_PROVIDERS`` — fails closed at the gate. 

1346# Keeping the set tight is the point: anything new defaults to refused. 

1347_LOCAL_DEFAULT_LLM_PROVIDERS = frozenset( 

1348 { 

1349 "ollama", 

1350 "lmstudio", 

1351 "llamacpp", 

1352 } 

1353) 

1354 

1355# Embeddings analogue of ``_LOCAL_DEFAULT_LLM_PROVIDERS``. Used by the 

1356# embeddings gate as a SNAPSHOT-LESS ALLOW-LIST: when ``get_embeddings`` 

1357# runs without a settings snapshot (the ``embeddings.require_local`` toggle 

1358# is then unreadable), only these localhost-default providers may proceed. 

1359# ``openai`` (and any future cloud embeddings provider) fails closed — 

1360# matching the LLM gate so a snapshot-less background caller can't ship the 

1361# local corpus to a cloud embedder. ``sentence_transformers`` runs fully 

1362# in-process; ``ollama`` defaults to a localhost endpoint. 

1363_LOCAL_DEFAULT_EMBEDDING_PROVIDERS = frozenset( 

1364 { 

1365 "sentence_transformers", 

1366 "ollama", 

1367 } 

1368) 

1369 

1370 

1371def _is_user_registered_llm( 

1372 provider: str, username: Optional[str] = None 

1373) -> bool: 

1374 """True when ``provider`` is a user-registered in-process LLM. 

1375 

1376 The programmatic API (``quick_summary(llms={"mock": ...})``) and plugins 

1377 register LLM objects in the LLM registry. Built-in providers are ALSO 

1378 auto-registered there by ``discover_providers()``, so registry membership 

1379 alone cannot discriminate — a name that is registered but NOT one of the 

1380 auto-discovered built-ins is user-supplied in-process code. Such objects 

1381 carry no configurable endpoint the PDP could classify (denying them with 

1382 ``provider_url_unset`` just breaks the documented offline workflow), the 

1383 operator injected them deliberately at code level, and under 

1384 PRIVATE_ONLY/STRICT the PEP-578 audit hook still blocks any stray 

1385 outbound socket they might open. 

1386 

1387 A user registration that *shadows* a built-in name (e.g. "openai") is NOT 

1388 treated as user code: the discovered-name check keeps it on the strict 

1389 path, and the ``_CLOUD_LLM_PROVIDERS`` gate fires first anyway. 

1390 

1391 Fails closed (False) on any error. 

1392 """ 

1393 try: 

1394 from ...llm.llm_registry import is_llm_registered 

1395 from ...llm.providers import discover_providers 

1396 from ...llm.providers.base import normalize_provider 

1397 

1398 if not provider or not is_llm_registered(provider, username=username): 

1399 return False 

1400 discovered = {normalize_provider(key) for key in discover_providers()} 

1401 return normalize_provider(provider) not in discovered 

1402 except Exception: # pragma: no cover - defensive 

1403 logger.bind(policy_audit=True).exception( 

1404 "user-registered LLM check failed", provider=provider 

1405 ) 

1406 return False 

1407 

1408 

1409def evaluate_llm_endpoint( 

1410 provider: str, 

1411 ctx: EgressContext, 

1412 *, 

1413 settings_snapshot: dict, 

1414 username: Optional[str] = None, 

1415) -> Decision: 

1416 """Decide whether an LLM provider may be instantiated. 

1417 

1418 Only meaningful when ``ctx.require_local_llm`` is True; otherwise allow. 

1419 

1420 ``username`` scopes the user-registered-LLM exemption to the caller's own 

1421 namespace so a per-user in-process LLM is recognized. It defaults to the 

1422 context's username when the caller does not pass one explicitly. 

1423 """ 

1424 if username is None: 

1425 username = getattr(ctx, "username", None) 

1426 if settings_snapshot is None: 

1427 return Decision(False, "no_snapshot") 

1428 try: 

1429 if not ctx.require_local_llm: 

1430 return Decision(True, "no_local_requirement") 

1431 

1432 # Hard-cloud providers always fail under require_local_llm. 

1433 if provider in _CLOUD_LLM_PROVIDERS: 

1434 return Decision(False, "provider_cloud_only") 

1435 

1436 # User-registered in-process LLMs (programmatic API / plugins) have 

1437 # no endpoint to classify; allow them — the audit hook still 

1438 # backstops stray sockets under PRIVATE_ONLY/STRICT. 

1439 if _is_user_registered_llm(provider, username=username): 

1440 return Decision(True, "user_registered_llm") 

1441 

1442 # Providers with a configurable URL: classify the URL. 

1443 url_key = f"llm.{provider}.url" 

1444 url_value = _get_setting_value(settings_snapshot, url_key, None) 

1445 if not url_value: 

1446 # Local-default providers (ollama, lmstudio, llamacpp) without 

1447 # an override fall back to their localhost defaults. Use the 

1448 # shared constant so this can't drift from the snapshot-less 

1449 # allow-list. 

1450 if provider in _LOCAL_DEFAULT_LLM_PROVIDERS: 

1451 return Decision(True, "provider_local_default") 

1452 return Decision(False, "provider_url_unset") 

1453 

1454 try: 

1455 parsed = urlsplit(url_value) 

1456 # Percent-decode the host before classifying — the HTTP client 

1457 # decodes it before connect, so "http://127%2e0%2e0%2e1:11434" 

1458 # must read as the local 127.0.0.1 (matches evaluate_url and 

1459 # _classify_engine_url). Without this a legitimate percent-encoded 

1460 # local endpoint is wrongly classified public and denied. 

1461 hostname = unquote(parsed.hostname) if parsed.hostname else None 

1462 except Exception: 

1463 return Decision(False, "url_malformed") 

1464 

1465 classification = _classify_host(hostname, ctx) if hostname else None 

1466 if classification is True: 

1467 return Decision(True, "provider_local") 

1468 return Decision(False, "provider_remote") 

1469 except Exception: # pragma: no cover - defensive 

1470 logger.bind(policy_audit=True).exception( 

1471 "evaluate_llm_endpoint internal error", provider=provider 

1472 ) 

1473 return Decision(False, "internal_error") 

1474 

1475 

1476def evaluate_embeddings( 

1477 provider: str, 

1478 ctx: EgressContext, 

1479 *, 

1480 settings_snapshot: dict, 

1481) -> Decision: 

1482 """Decide whether an embeddings provider may be instantiated. 

1483 

1484 Only meaningful when ``ctx.require_local_embeddings`` is True. 

1485 OpenAI is treated as unambiguously cloud unless ``embeddings.openai.base_url`` 

1486 is configured to a local host. 

1487 """ 

1488 if settings_snapshot is None: 

1489 return Decision(False, "no_snapshot") 

1490 try: 

1491 if not ctx.require_local_embeddings: 

1492 return Decision(True, "no_local_requirement") 

1493 

1494 if provider == "sentence_transformers": 

1495 return Decision(True, "provider_local") 

1496 

1497 if provider == "ollama": 

1498 url = _get_setting_value( 

1499 settings_snapshot, "embeddings.ollama.url", None 

1500 ) or _get_setting_value(settings_snapshot, "llm.ollama.url", None) 

1501 if not url: 

1502 return Decision(True, "provider_local_default") 

1503 parsed = urlsplit(url) 

1504 host = unquote(parsed.hostname) if parsed.hostname else None 

1505 classification = _classify_host(host, ctx) if host else None 

1506 return ( 

1507 Decision(True, "provider_local") 

1508 if classification is True 

1509 else Decision(False, "provider_remote") 

1510 ) 

1511 

1512 if provider == "openai": 

1513 base_url = _get_setting_value( 

1514 settings_snapshot, "embeddings.openai.base_url", None 

1515 ) 

1516 if base_url: 

1517 parsed = urlsplit(base_url) 

1518 host = unquote(parsed.hostname) if parsed.hostname else None 

1519 classification = _classify_host(host, ctx) if host else None 

1520 if classification is True: 

1521 return Decision(True, "provider_local_endpoint") 

1522 return Decision(False, "provider_cloud") 

1523 

1524 return Decision(False, "provider_unknown") 

1525 except Exception: # pragma: no cover - defensive 

1526 logger.bind(policy_audit=True).exception( 

1527 "evaluate_embeddings internal error", provider=provider 

1528 ) 

1529 return Decision(False, "internal_error") 

1530 

1531 

1532_DANGEROUS_SCHEMES = frozenset( 

1533 {"javascript", "data", "file", "vbscript", "about"} 

1534) 

1535 

1536# Cloud-metadata endpoints reachable by HOSTNAME rather than literal IP. GCP's 

1537# IMDS answers on metadata.google.internal / metadata.goog (resolving to 

1538# 169.254.169.254); is_ip_blocked only catches the IP literal, so these names 

1539# need an explicit block to honor the "metadata is NEVER permitted, regardless 

1540# of scope" invariant in evaluate_url. AWS/Azure/Alibaba IMDS have no such 

1541# hostname (IP only) and are covered by the is_ip_blocked / alt-encoding path. 

1542_METADATA_HOSTNAMES = frozenset({"metadata.google.internal", "metadata.goog"}) 

1543 

1544 

1545def _normalize_alt_ipv4(host: str) -> Optional[str]: 

1546 """Return the canonical dotted-quad for an IPv4 host written in an 

1547 alternate encoding the libc resolver accepts but ``ipaddress.ip_address`` 

1548 rejects — octal (``0251.0376.0251.0376``), hex (``0xa9fea9fe``), integer 

1549 (``2852039166``) and short forms (``169.254.43518``). Returns ``None`` for 

1550 anything ``inet_aton`` won't parse (real hostnames, IPv6, junk). 

1551 

1552 ``socket.getaddrinfo`` resolves these numeric forms to the same address the 

1553 HTTP client will ``connect()`` to, so the metadata-IP block in 

1554 ``evaluate_url`` must classify them by their *real* target — otherwise 

1555 ``http://0251.0376.0251.0376/`` slips past ``is_ip_blocked`` (which only 

1556 parses canonical notation) and reads as an allowed public host. 

1557 """ 

1558 try: 

1559 import socket 

1560 

1561 return socket.inet_ntoa(socket.inet_aton(host)) 

1562 except (OSError, UnicodeError, TypeError): 

1563 return None 

1564 

1565 

1566def _quota_ctx(ctx: EgressContext) -> EgressContext: 

1567 """Return the EgressContext whose counter backs the per-RUN denied-fetch 

1568 quota. 

1569 

1570 Each call site (ContentFetcher, full_search, download_service, the audit 

1571 hook) builds its OWN EgressContext from the snapshot, so a per-context 

1572 counter would reset the budget every time a new engine/fetcher is built — 

1573 letting a malicious document evade ``MAX_DENIED_FETCHES_PER_RUN`` by 

1574 spreading denied fetches across contexts. The run's ARMED active context is 

1575 set once at run start and re-armed identically on pool workers, so it is 

1576 shared across the whole run; anchoring the counter there makes the quota 

1577 truly per-run. Falls back to the passed ctx when no run context is armed 

1578 (snapshot-less / programmatic callers, settings-page calls), keeping those 

1579 isolated calls self-contained. 

1580 """ 

1581 try: 

1582 from .audit_hook import get_active_context 

1583 

1584 active = get_active_context() 

1585 except Exception: # pragma: no cover - defensive 

1586 active = None 

1587 return active if active is not None else ctx 

1588 

1589 

1590def evaluate_url(url: str, ctx: EgressContext) -> Decision: 

1591 """Decide whether an arbitrary URL may be fetched (e.g., by ``fetch_content``). 

1592 

1593 Enforces the run's denied-fetch quota to prevent exhaustion attacks 

1594 via malicious indexed documents that loop the agent through hundreds 

1595 of denied fetches. 

1596 """ 

1597 try: 

1598 # Quota check: hard fail after MAX_DENIED_FETCHES_PER_RUN. Anchor the 

1599 # counter to the run's active context (``_quota_ctx``) so the budget is 

1600 # per-RUN, not per-EgressContext. Read under that ctx's lock so 

1601 # concurrent subagents see a consistent count. 

1602 qctx = _quota_ctx(ctx) 

1603 with qctx._lock: 

1604 if qctx._fetch_denial_count["count"] >= MAX_DENIED_FETCHES_PER_RUN: 

1605 return Decision(False, "denial_quota_exceeded") 

1606 

1607 if not isinstance(url, str) or not url: 

1608 return _record_denial(ctx, url, "url_malformed") 

1609 

1610 try: 

1611 parsed = urlsplit(url) 

1612 except Exception: 

1613 return _record_denial(ctx, url, "url_malformed") 

1614 

1615 if parsed.scheme.lower() in _DANGEROUS_SCHEMES: 

1616 return _record_denial(ctx, url, "dangerous_scheme") 

1617 if parsed.scheme.lower() not in ("http", "https"): 

1618 return _record_denial(ctx, url, "unsupported_scheme") 

1619 if not parsed.hostname: 

1620 return _record_denial(ctx, url, "no_hostname") 

1621 

1622 # HTTP client libraries (requests/urllib3) percent-DECODE the host 

1623 # in the netloc before the socket connect, but urlsplit().hostname 

1624 # preserves the encoding. Classifying the encoded form lets 

1625 # "http://192%2e168%2e1%2e1/" read as an unresolvable public host 

1626 # (DNS fails) while the client actually connects to the private 

1627 # 192.168.1.1 — a scope bypass under PUBLIC_ONLY. Classify the 

1628 # DECODED host so the policy sees the real connect target. 

1629 host = unquote(parsed.hostname) 

1630 # Trailing dots are insignificant to the resolver (getaddrinfo strips 

1631 # them), so "169.254.169.254." and "metadata.google.internal." must be 

1632 # classified identically to their bare forms — otherwise a trailing 

1633 # dot dodges the metadata checks below. Mirrors the SSRF validator's 

1634 # rstrip("."). 

1635 host = host.rstrip(".") 

1636 

1637 # Cloud-metadata endpoints (AWS/GCE/Azure IMDS at 169.254.169.254 

1638 # etc.) are NEVER permitted, regardless of scope. They classify as 

1639 # link-local/private, so STRICT and PRIVATE_ONLY would otherwise 

1640 # ALLOW them — a credential-theft path for prompt-injected fetches 

1641 # and, more importantly, for the audit-hook net which calls 

1642 # evaluate_url directly on raw socket.connect targets (bypassing the 

1643 # SSRF validator that the explicit fetch PEPs run first). Reuse 

1644 # is_ip_blocked(allow_private_ips=True), which returns True only for 

1645 # the always-blocked metadata set (+ NAT64 wraps) and also unwraps 

1646 # IPv4-mapped IPv6 forms. 

1647 try: 

1648 from ..ssrf_validator import is_ip_blocked 

1649 

1650 if is_ip_blocked( 

1651 host, allow_localhost=True, allow_private_ips=True 

1652 ): 

1653 return _record_denial(ctx, url, "blocked_metadata_ip") 

1654 # is_ip_blocked only parses canonical IP notation, so an 

1655 # alternate-encoded metadata literal (octal/hex/integer) slips 

1656 # through above. Normalize it to the dotted-quad the resolver 

1657 # would connect to and re-check, so the "metadata IPs are NEVER 

1658 # permitted, regardless of scope" invariant holds for those forms 

1659 # too — and they get the explicit blocked_metadata_ip reason 

1660 # rather than leaking into the scope-mismatch bucket. 

1661 alt = _normalize_alt_ipv4(host) 

1662 if ( 

1663 alt is not None 

1664 and alt != host 

1665 and is_ip_blocked( 

1666 alt, allow_localhost=True, allow_private_ips=True 

1667 ) 

1668 ): 

1669 return _record_denial(ctx, url, "blocked_metadata_ip") 

1670 # Metadata endpoints reachable by hostname (GCP) — is_ip_blocked 

1671 # can't see these because they aren't IP literals. Block by name so 

1672 # the invariant holds under PUBLIC_ONLY/BOTH too (the SSRF validator 

1673 # backstops the actual fetch, but evaluate_url's own guarantee must 

1674 # not depend on it). 

1675 if host.lower() in _METADATA_HOSTNAMES: 

1676 return _record_denial(ctx, url, "blocked_metadata_ip") 

1677 except Exception: # noqa: silent-exception - defensive, see below 

1678 # Non-IP host or helper error: fall through to normal 

1679 # classification (DNS path has its own handling). 

1680 pass 

1681 

1682 # UNPROTECTED: egress protection disabled — any host is allowed. This 

1683 # gate is deliberately AFTER the SSRF / cloud-metadata block above 

1684 # (which always applies), so the escape hatch only lifts the 

1685 # egress-scope host restriction, never the hard SSRF invariant. 

1686 if ctx.scope == EgressScope.UNPROTECTED: 

1687 return Decision(True, "egress_unprotected") 

1688 

1689 classification = _classify_host(host, ctx) 

1690 

1691 if ctx.scope == EgressScope.STRICT: 

1692 # Under STRICT, URLs whose host is private are allowed; public 

1693 # hosts (DOI redirects, citations) are not. This is a deliberate 

1694 # trade-off: prompt-injection spoofing of provenance tags is a 

1695 # bigger risk than false-positives on DOI redirects. 

1696 if classification is True: 

1697 return Decision(True, "allowed_private_host_under_strict") 

1698 return _record_denial(ctx, url, "strict_public_host") 

1699 

1700 if ctx.scope == EgressScope.PUBLIC_ONLY: 

1701 if classification is False: 

1702 return Decision(True, "allowed_public_host") 

1703 return _record_denial(ctx, url, "scope_mismatch_public_only") 

1704 

1705 if ctx.scope == EgressScope.PRIVATE_ONLY: 

1706 if classification is True: 

1707 return Decision(True, "allowed_private_host") 

1708 return _record_denial(ctx, url, "scope_mismatch_private_only") 

1709 

1710 # BOTH: any classified host is fine. 

1711 if classification is None: 1711 ↛ 1712line 1711 didn't jump to line 1712 because the condition on line 1711 was never true

1712 return _record_denial(ctx, url, "host_unclassified") 

1713 return Decision(True, "allowed_both_scope") 

1714 except Exception: # pragma: no cover - defensive 

1715 logger.bind(policy_audit=True).exception("evaluate_url internal error") 

1716 return Decision(False, "internal_error") 

1717 

1718 

1719def _record_denial(ctx: EgressContext, url: object, reason: str) -> Decision: 

1720 """Increment the denial counter inside the frozen context's mutable dict 

1721 and emit a redacted audit log line. Lock guards the read-modify-write 

1722 against concurrent subagent threads. 

1723 

1724 The offending URL is inlined into the WARNING message body rather than 

1725 bound as a kwarg, matching the rest of the app's log convention (see 

1726 ``[FILE_INTEGRITY]`` audit lines, successful-fetch ``[N] Title: ... URL: ...`` 

1727 observations). The ``database_sink`` persists ``record["message"]`` 

1728 verbatim into ``app_logs.message`` — and that is what the JSONL exporter 

1729 emits — so this is the only layer at which a triager can read the URL. 

1730 

1731 Note: after ``langgraph_agent_strategy._observation_event`` was taught 

1732 to return ``None`` for ``fetch_content`` denial/error strings, the chat 

1733 panel no longer emits a ``📄 From the page: Cannot fetch …`` MILESTONE 

1734 for denials. The persisted WARNING is therefore the **only** record of 

1735 the denial in the research log — if the URL is missing from this 

1736 message body, there is nothing else to cross-reference. The URL still 

1737 passes through ``redact_url_for_log`` so tokens, query strings, and 

1738 paths never reach the audit log. 

1739 """ 

1740 from ..ssrf_validator import redact_url_for_log 

1741 

1742 counts_toward_quota = reason not in _NON_QUOTA_DENIAL_REASONS 

1743 # Increment the per-RUN counter (the run's active context, shared across 

1744 # every call-site context) rather than this one context's — see _quota_ctx. 

1745 qctx = _quota_ctx(ctx) 

1746 with qctx._lock: 

1747 if counts_toward_quota: 

1748 qctx._fetch_denial_count["count"] += 1 

1749 count = qctx._fetch_denial_count["count"] 

1750 # ``url_malformed`` and the ``urlsplit``-except path can hand us a 

1751 # non-string (None, bytes, int) — urllib3.parse_url raises TypeError on 

1752 # int/bytes and returns a Url with scheme=None for None, neither of which 

1753 # we want on the audit line. Coerce to a stable, type-tagged placeholder 

1754 # so the WARNING still carries a useful field even when the URL itself 

1755 # was unparseable. The denial decision is unaffected — we already 

1756 # decided to refuse. 

1757 if not isinstance(url, str): 

1758 audit_url = f"<unparseable:{type(url).__name__}>" 

1759 else: 

1760 audit_url = redact_url_for_log(url) 

1761 # The URL / reason / scope / count / counted must ride on the message 

1762 # body, NOT as logger.warning kwargs. database_sink persists 

1763 # record["message"] into ``app_logs.message`` verbatim and only copies a 

1764 # small fixed set of structured fields into the DB row. It *does* read 

1765 # selected routing metadata from ``record["extra"]`` (for example 

1766 # ``research_id`` / ``username``), but arbitrary extras or warning kwargs 

1767 # are not serialized into ``app_logs.message`` (or the JSONL exporter that 

1768 # reads it). The pinned 

1769 # ``test_record_denial_warning_message_carries_redacted_url`` and 

1770 # ``test_record_denial_warning_message_reaches_persisted_log`` tests 

1771 # assert this invariant. 

1772 logger.bind(policy_audit=True).warning( 

1773 f"policy denied URL fetch url={audit_url} reason={reason} " 

1774 f"scope={ctx.scope.value} count={count} counted={counts_toward_quota}" 

1775 ) 

1776 return Decision(False, reason) 

1777 

1778 

1779def evaluate_retriever( 

1780 retriever_name: str, 

1781 ctx: EgressContext, 

1782 *, 

1783 metadata: Optional[dict] = None, 

1784) -> Decision: 

1785 """Decide whether a registered retriever may be invoked. 

1786 

1787 Reads classification (``{"is_local": bool}``) set at registration 

1788 time. Unclassified retrievers fail closed. ``metadata`` may be 

1789 supplied by the caller (e.g. the search-engine factory, which has 

1790 already looked up the retriever in its own registry reference) to 

1791 avoid a second registry lookup and to honor a test-patched registry; 

1792 when ``None`` the global registry is consulted. 

1793 """ 

1794 try: 

1795 # UNPROTECTED: egress protection disabled — any retriever is permitted. 

1796 if ctx.scope == EgressScope.UNPROTECTED: 

1797 return Decision(True, "egress_unprotected") 

1798 

1799 if metadata is None: 

1800 from ...web_search_engines.retriever_registry import ( 

1801 retriever_registry, 

1802 ) 

1803 

1804 try: 

1805 metadata = retriever_registry.get_metadata( 

1806 retriever_name, username=ctx.username 

1807 ) 

1808 except AttributeError: 

1809 # Older registry without metadata API; treat as unclassified. 

1810 return Decision(False, "retriever_unclassified") 

1811 

1812 if not metadata: 

1813 return Decision(False, "retriever_unknown") 

1814 

1815 is_local = metadata.get("is_local") 

1816 if is_local is None: 1816 ↛ 1817line 1816 didn't jump to line 1817 because the condition on line 1816 was never true

1817 return Decision(False, "retriever_unclassified") 

1818 

1819 if ctx.scope == EgressScope.STRICT: 

1820 # STRICT permits only the user's primary engine. A retriever 

1821 # IS allowed under STRICT when it is itself the primary (the 

1822 # common "research only against my private KB" setup); 

1823 # otherwise it's an expansion STRICT forbids. 

1824 if retriever_name == ctx.primary_engine: 

1825 return Decision(True, "allowed_primary_retriever") 

1826 return Decision(False, "strict_not_primary") 

1827 if ctx.scope == EgressScope.PUBLIC_ONLY and is_local: 

1828 return Decision(False, "scope_mismatch_public_only") 

1829 if ctx.scope == EgressScope.PRIVATE_ONLY and not is_local: 1829 ↛ 1830line 1829 didn't jump to line 1830 because the condition on line 1829 was never true

1830 return Decision(False, "scope_mismatch_private_only") 

1831 return Decision(True, "allowed") 

1832 except Exception: # pragma: no cover - defensive 

1833 logger.bind(policy_audit=True).exception( 

1834 "evaluate_retriever internal error", retriever=retriever_name 

1835 ) 

1836 return Decision(False, "internal_error") 

1837 

1838 

1839def _retriever_is_local( 

1840 retriever_name: str, username: Optional[str] 

1841) -> Optional[bool]: 

1842 """Return the ``is_local`` classification (True/False) of a registered 

1843 retriever, or ``None`` when it is unknown/unclassified. 

1844 

1845 Mirrors the registry lookup in ``evaluate_retriever`` so adaptive-scope 

1846 resolution and enforcement agree on a retriever primary's classification. 

1847 """ 

1848 try: 

1849 from ...web_search_engines.retriever_registry import ( 

1850 retriever_registry, 

1851 ) 

1852 

1853 metadata = retriever_registry.get_metadata( 

1854 retriever_name, username=username 

1855 ) 

1856 except Exception: # noqa: silent-exception - unknown → caller falls back 

1857 return None 

1858 if not metadata: 

1859 return None 

1860 is_local = metadata.get("is_local") 

1861 return is_local if isinstance(is_local, bool) else None 

1862 

1863 

1864# Bounded, thread-safe, PER-USER dedup for the ADAPTIVE fail-open WARNING 

1865# emitted below. ``context_from_snapshot`` runs at every research-run start 

1866# AND at every per-URL fetch-gate check (``BaseSearchEngine. 

1867# _build_full_search_egress_context``, called once per fetched URL), so an 

1868# unclassifiable primary would otherwise emit one identical WARNING per 

1869# fetched URL -- potentially hundreds per run. We remember, PER USER, which 

1870# (sanitized) primary engines have already warned, so a genuinely new 

1871# (user, primary) combination still logs once while repeats are silent. 

1872# 

1873# The dedup MUST be keyed on the actual user identity (a stable hash of the 

1874# username, ``None`` bucket for an unauthenticated / un-threaded run) -- NOT 

1875# merely on "was a username present". Keying on presence alone means a 

1876# genuine fail-open for user B on a primary name already logged for user A is 

1877# silently swallowed: the audit signal for B never fires. Keying on identity 

1878# keeps each user's fail-opens independently detectable. 

1879# 

1880# ``primary_engine`` is user-controlled (``search.tool``), so the dedup state 

1881# is bounded in BOTH dimensions -- mirroring the capped ``warned: set`` dedup 

1882# in ``web/services/socketio_asgi.py::_install_origin_rejection_logging``: 

1883# * each user's bucket caps the distinct primaries it remembers 

1884# (``_ADAPTIVE_FAILOPEN_WARN_CAP_PER_USER``); once full it stops 

1885# warning/recording, exactly as the old single global set did -- but now 

1886# that ceiling is reached PER USER, so it only ever silences the ONE user 

1887# who flooded it, and 

1888# * the set of tracked users is a bounded LRU 

1889# (``_ADAPTIVE_FAILOPEN_MAX_USERS``); the least-recently-seen user's whole 

1890# bucket is evicted beyond the cap (which can only ever re-enable a 

1891# warning, never suppress one). 

1892# A single authenticated user flooding distinct junk primaries therefore only 

1893# ever exhausts THEIR OWN bucket -- it can never suppress the fail-open audit 

1894# warnings of any other user (the bug a single global set had: 512 junk 

1895# primaries from one user permanently silenced everyone). Total memory is 

1896# capped at MAX_USERS * WARN_CAP_PER_USER short strings. 

1897_ADAPTIVE_FAILOPEN_WARNED: "OrderedDict[Optional[str], OrderedDict[str, None]]" = OrderedDict() 

1898_ADAPTIVE_FAILOPEN_LOCK = threading.Lock() 

1899# Max distinct (sanitized) primaries remembered per user; once a user's bucket 

1900# is full we stop warning/recording for that user (memory-safe, and preserves 

1901# the original anti-log-amplification behavior) -- but PER USER, so it never 

1902# touches another user's audit budget. 

1903_ADAPTIVE_FAILOPEN_WARN_CAP_PER_USER = 128 

1904# Max distinct users tracked at once; the least-recently-seen user's whole 

1905# bucket is LRU-evicted beyond this, keeping total memory bounded. 

1906_ADAPTIVE_FAILOPEN_MAX_USERS = 256 

1907 

1908 

1909def _adaptive_failopen_user_key(username: Optional[str]) -> Optional[str]: 

1910 """Stable, bounded-length bucket key for the per-user fail-open dedup. 

1911 

1912 ``None`` (unauthenticated / un-threaded run) is its own bucket. A present 

1913 username is hashed rather than stored raw: it bounds the key length 

1914 regardless of the username, and avoids retaining plaintext identities in 

1915 this long-lived process-global audit structure (the warning itself only 

1916 ever emits ``username_present``, never the name). 

1917 """ 

1918 if username is None: 

1919 return None 

1920 return hashlib.sha256(username.encode("utf-8")).hexdigest()[:16] 

1921 

1922 

1923def _should_warn_adaptive_failopen( 

1924 sanitized_primary: str, username: Optional[str] 

1925) -> bool: 

1926 """True the first time this ``(user, sanitized_primary)`` pair is seen. 

1927 

1928 Dedup is PER USER: the bucket key is a stable hash of ``username`` 

1929 (``None`` for an unauthenticated run), so a genuine fail-open for one user 

1930 is never swallowed by an already-logged fail-open for a DIFFERENT user on 

1931 the same primary name. Returns False for a repeat of an already-recorded 

1932 pair, and for any NEW primary once *this* user's bucket is full (bounded, 

1933 and only that user is affected). The set of tracked users is a bounded LRU 

1934 -- see the ``_ADAPTIVE_FAILOPEN_WARNED`` docstring above for the 

1935 memory-safety and cross-user-isolation rationale. 

1936 """ 

1937 user_key = _adaptive_failopen_user_key(username) 

1938 with _ADAPTIVE_FAILOPEN_LOCK: 

1939 bucket = _ADAPTIVE_FAILOPEN_WARNED.get(user_key) 

1940 if bucket is None: 

1941 # First fail-open for this user. Enforce the users-tracked cap by 

1942 # LRU-evicting the least-recently-seen user before inserting. 

1943 if len(_ADAPTIVE_FAILOPEN_WARNED) >= _ADAPTIVE_FAILOPEN_MAX_USERS: 

1944 _ADAPTIVE_FAILOPEN_WARNED.popitem(last=False) 

1945 bucket = OrderedDict() 

1946 _ADAPTIVE_FAILOPEN_WARNED[user_key] = bucket 

1947 else: 

1948 # Seen this user before -> mark most-recently-used so an active 

1949 # user is never the one evicted by the users cap. 

1950 _ADAPTIVE_FAILOPEN_WARNED.move_to_end(user_key) 

1951 

1952 if sanitized_primary in bucket: 

1953 return False 

1954 # This user's per-user budget is full: stop warning AND recording, so 

1955 # one user's junk-primary flood can neither grow memory nor amplify 

1956 # logs. Bounded and scoped to this ONE user's bucket -- every other 

1957 # user keeps their full audit budget. 

1958 if len(bucket) >= _ADAPTIVE_FAILOPEN_WARN_CAP_PER_USER: 

1959 return False 

1960 bucket[sanitized_primary] = None 

1961 return True 

1962 

1963 

1964def _resolve_adaptive_scope( 

1965 primary_engine: str, 

1966 settings_snapshot: dict, 

1967 *, 

1968 username: Optional[str], 

1969 local_hostnames: tuple, 

1970 allow_dns: bool = True, 

1971) -> EgressScope: 

1972 """Map ADAPTIVE to a concrete scope by classifying the primary engine. 

1973 

1974 Uses the engine's **static class flags** (plus ``classify_engine``'s 

1975 fail-up URL override) so that e.g. a locally-hosted SearXNG is still 

1976 treated as a public engine, and a remote-hosted Elasticsearch resolves 

1977 public rather than pulling the run into PRIVATE_ONLY. 

1978 

1979 private primary => PRIVATE_ONLY, public primary => PUBLIC_ONLY, 

1980 unknown / classification-error => BOTH (adaptive's documented 

1981 permissive fallback, equivalent to pre-policy behavior, so a 

1982 classification hiccup never hard-fails the run). 

1983 

1984 A registered LangChain retriever (private KB) is not in ENGINE_REGISTRY, 

1985 so ``classify_engine`` returns no classification for it; we then consult the 

1986 retriever registry so a local retriever primary resolves to PRIVATE_ONLY 

1987 (forcing local inference) rather than leaking the corpus to cloud models. 

1988 """ 

1989 if not primary_engine: 

1990 return EgressScope.BOTH 

1991 try: 

1992 probe_ctx = EgressContext( 

1993 scope=EgressScope.BOTH, 

1994 primary_engine=primary_engine, 

1995 require_local_llm=False, 

1996 require_local_embeddings=False, 

1997 local_hostnames=local_hostnames, 

1998 username=username, 

1999 ) 

2000 classification = classify_engine( 

2001 primary_engine, 

2002 probe_ctx, 

2003 settings_snapshot=settings_snapshot, 

2004 allow_dns=allow_dns, 

2005 ) 

2006 except Exception: # noqa: silent-exception - adaptive falls back to BOTH 

2007 logger.bind(policy_audit=True).debug( 

2008 "adaptive scope classification failed; falling back to BOTH", 

2009 primary=primary_engine, 

2010 ) 

2011 return EgressScope.BOTH 

2012 

2013 if classification.is_local is True and classification.is_public is not True: 

2014 return EgressScope.PRIVATE_ONLY 

2015 if classification.is_public is True and classification.is_local is not True: 

2016 return EgressScope.PUBLIC_ONLY 

2017 

2018 # SECURITY (fail-open regression): a PRIVATE non-contained collection — 

2019 # its vector store lives off this machine — classifies as 

2020 # (is_public=False, is_local=False). ``classify_engine``'s fail-up logic 

2021 # (see its docstring) strips ``is_local`` when containment is lost while 

2022 # PRESERVING the real ``is_public`` flag, so this is the mirror image of 

2023 # a PUBLIC non-contained collection: that one correctly matches the 

2024 # ``is_public is True`` branch above and resolves PUBLIC_ONLY, but a 

2025 # PRIVATE one matches NEITHER exclusive branch and, without this rule, 

2026 # fell through all the way to the permissive ``BOTH`` fallback below — 

2027 # silently dropping the forced-local-LLM/embeddings guarantee 

2028 # (``context_from_snapshot``'s PRIVATE_ONLY coupling) for exactly the 

2029 # collection that most needs it, and resolving MORE permissively than 

2030 # the public case despite being the more sensitive one. Resolve to the 

2031 # RESTRICTIVE PRIVATE_ONLY instead — this preserves the pre-PR 

2032 # forced-local posture. The collection engine itself still gets denied 

2033 # by ``evaluate_engine`` as "unclassified" (neither flag is True) 

2034 # regardless of the scope resolved here; PRIVATE_ONLY's role is to keep 

2035 # every OTHER engine/inference path in the run local too, rather than 

2036 # letting the run silently widen to cloud-capable BOTH. This branch is 

2037 # reached via the private-non-contained-collection fail-up above, or by a 

2038 # static engine whose class overrides NEITHER flag: ``BaseSearchEngine`` 

2039 # declares ``is_public = False`` and ``is_local = False`` as CLASS 

2040 # DEFAULTS (``search_engine_base.py``), so such a subclass inherits 

2041 # ``(False, False)`` from the base class — it need not explicitly write 

2042 # either assignment itself — and lands HERE, not in the ``(None, None)`` 

2043 # branch below. ``_engine_flags`` only returns ``None`` for a flag that is 

2044 # genuinely absent from the class's MRO, which no attribute on 

2045 # ``BaseSearchEngine`` ever is; all 29 current registry engines override 

2046 # exactly one flag to ``True`` and so don't reach this branch today, but 

2047 # the next engine author who forgets to override either flag will, not 

2048 # the retriever-lookup fallback. Dedup/log via the same per-(user, primary) mechanism 

2049 # as the fail-open warning below — this fires on the identical hot path 

2050 # (once per research-run start AND once per fetch-gate check). 

2051 if classification.is_public is False and classification.is_local is False: 

2052 safe_primary = sanitize_for_log(primary_engine) 

2053 if _should_warn_adaptive_failopen(f"nonlocal:{safe_primary}", username): 

2054 logger.bind(policy_audit=True).warning( 

2055 f"ADAPTIVE egress: primary engine '{safe_primary}' classified " 

2056 f"as neither public nor local-contained (a PRIVATE collection " 

2057 f"whose vector store is not on this machine, or a " 

2058 f"mis-declared static engine); resolving to the RESTRICTIVE " 

2059 f"PRIVATE_ONLY scope (forcing local LLM/embeddings) rather " 

2060 f"than the permissive BOTH." 

2061 ) 

2062 return EgressScope.PRIVATE_ONLY 

2063 

2064 # Unknown to classify_engine — it may be a registered retriever (private 

2065 # KB). Classify via the retriever registry before falling back to BOTH. 

2066 if classification.is_public is None and classification.is_local is None: 

2067 retriever_local = _retriever_is_local(primary_engine, username) 

2068 if retriever_local is True: 

2069 return EgressScope.PRIVATE_ONLY 

2070 if retriever_local is False: 

2071 return EgressScope.PUBLIC_ONLY 

2072 

2073 # SECURITY (detectable fail-open): the primary could not be classified 

2074 # in ANY namespace visible here — it is not a static engine, not a 

2075 # collection, and not a retriever resolvable for ``username``. ADAPTIVE 

2076 # therefore resolves to the PERMISSIVE ``BOTH`` (cloud-capable) scope. 

2077 # 

2078 # The benign cause is a genuinely-unregistered / typo'd / removed 

2079 # meta-engine primary. The DANGEROUS cause is a per-user PRIVATE 

2080 # retriever that is invisible here because ``username=`` was not 

2081 # threaded into this resolution (or the snapshot lacked ``_username``): 

2082 # the retriever would classify as local (=> PRIVATE_ONLY, forcing local 

2083 # inference) if its owner's namespace were consulted, but under the 

2084 # wrong/absent username it disappears and the run silently fails open 

2085 # to cloud-capable BOTH. 

2086 # 

2087 # We do NOT fail closed here: reaching this branch means classify_engine 

2088 # returned (None, None), i.e. a *legitimately-public* primary can never 

2089 # land here (a public engine classifies is_public=True => PUBLIC_ONLY 

2090 # above). But we still cannot cleanly separate "benign unregistered 

2091 # primary" from "invisible per-user retriever" without peeking across 

2092 # other users' registry namespaces (breaking the isolation this PR 

2093 # establishes, and misfiring PRIVATE_ONLY onto a legitimate 

2094 # unregistered public primary that merely shares a name). Since the 

2095 # distinction isn't clean, per the security-hardening guidance we keep 

2096 # the non-breaking permissive behavior and make it DETECTABLE instead: 

2097 # a missed ``username=`` threading now shows up as this WARNING rather 

2098 # than silently permissive egress. 

2099 # primary_engine is user-controlled (search.tool): scrub it before 

2100 # interpolating into the log line so embedded newlines/control chars 

2101 # can't forge additional log entries (log injection). Dedup on the 

2102 # SANITIZED value too, per _should_warn_adaptive_failopen above, so 

2103 # this fires at most once per unique (user, primary) pair in the 

2104 # process rather than once per call. The dedup keys on the ACTUAL 

2105 # username (hashed), not merely "was one present", so a genuine 

2106 # fail-open for one user is never swallowed by another user's. 

2107 safe_primary = sanitize_for_log(primary_engine) 

2108 if _should_warn_adaptive_failopen(safe_primary, username): 

2109 logger.bind(policy_audit=True).warning( 

2110 f"ADAPTIVE egress fail-open: primary engine " 

2111 f"'{safe_primary}' could not be classified in any visible " 

2112 f"namespace (static engine, collection, or retriever visible to " 

2113 f"this user); resolving PERMISSIVELY to BOTH (cloud-capable). If " 

2114 f"'{safe_primary}' is a per-user PRIVATE retriever this is a " 

2115 f"fail-open — verify username= is threaded into egress " 

2116 f"resolution (username_present={username is not None})." 

2117 ) 

2118 return EgressScope.BOTH 

2119 

2120 return EgressScope.BOTH 

2121 

2122 

2123def context_from_snapshot( 

2124 settings_snapshot: dict, 

2125 primary_engine: str, 

2126 *, 

2127 username: Optional[str] = None, 

2128 allow_dns: bool = True, 

2129) -> EgressContext: 

2130 """Construct the frozen ``EgressContext`` for a research run. 

2131 

2132 Reads policy settings out of the snapshot exactly once at run-start. 

2133 A missing ``policy.egress_scope`` yields ``DEFAULT_EGRESS_SCOPE`` 

2134 (``adaptive`` — the protective default that follows the primary engine), 

2135 NOT a permissive fallback. A residual ``both`` value is coerced to 

2136 ``adaptive`` below. 

2137 

2138 ``allow_dns=False`` makes ADAPTIVE resolution skip the synchronous 

2139 getaddrinfo when classifying a URL-configurable primary engine — used by 

2140 the advisory warning-banner render path so a settings-page load never 

2141 blocks on DNS. Enforcement callers leave it True so classification is 

2142 accurate. (Has no effect for non-ADAPTIVE scopes, which never resolve 

2143 a URL.) 

2144 """ 

2145 if settings_snapshot is None: 

2146 raise ValueError("settings_snapshot is required") 

2147 if not isinstance(settings_snapshot, dict): 

2148 # Fail closed with the same ValueError contract callers already 

2149 # handle (llm_config / research_service convert it to a hard policy 

2150 # stop). A non-dict snapshot would otherwise crash deeper in 

2151 # _get_setting_value with a bare AttributeError, which a broad 

2152 # caller-side except could swallow into a permissive default. 

2153 raise ValueError( 

2154 f"settings_snapshot must be a dict, got " 

2155 f"{type(settings_snapshot).__name__}" 

2156 ) 

2157 

2158 scope_raw = _get_setting_value( 

2159 settings_snapshot, "policy.egress_scope", DEFAULT_EGRESS_SCOPE 

2160 ) 

2161 from ...settings.manager import check_env_setting 

2162 

2163 env_scope = check_env_setting("policy.egress_scope") 

2164 if env_scope is not None: 

2165 scope_raw = env_scope 

2166 # `both` is retired (ADR-0007): the blanket-permissive middle scope is 

2167 # coerced to the protective `adaptive` default so a residual value — an 

2168 # un-migrated DB, a queued snapshot, or an env-var override that bypasses 

2169 # settings validation — can never silently keep the old no-protection 

2170 # behaviour. Migration 0019 rewrites stored values; this is the read-time 

2171 # backstop. (EgressScope.BOTH still exists as the INTERNAL resolution result 

2172 # for an unclassifiable ADAPTIVE primary — it is never a user-facing scope.) 

2173 if str(scope_raw).strip().lower() == EgressScope.BOTH.value: 

2174 scope_raw = EgressScope.ADAPTIVE.value 

2175 try: 

2176 scope = parse_user_egress_scope( 

2177 scope_raw, disabled_unprotected="adaptive" 

2178 ) 

2179 except PolicyDeniedError: 

2180 # An unrecognised scope means the saved setting was corrupted 

2181 # or tampered with — silently falling back to BOTH (the most 

2182 # permissive scope) would mask policy violations rather than 

2183 # surface them. Refuse the run instead so the operator notices. 

2184 logger.bind(policy_audit=True).warning( 

2185 "refusing to construct EgressContext from unknown scope", 

2186 value=scope_raw, 

2187 ) 

2188 raise 

2189 

2190 if ( 

2191 str(scope_raw).strip().lower() == EgressScope.UNPROTECTED.value 

2192 and scope == EgressScope.ADAPTIVE 

2193 ): 

2194 logger.bind(policy_audit=True).warning( 

2195 "unprotected egress is disabled; coercing scope to adaptive" 

2196 ) 

2197 

2198 env_require_local_llm = check_env_setting("llm.require_local_endpoint") 

2199 env_require_local_embeddings = check_env_setting("embeddings.require_local") 

2200 require_local_llm = _coerce_bool( 

2201 env_require_local_llm 

2202 if env_require_local_llm is not None 

2203 else _get_setting_value( 

2204 settings_snapshot, "llm.require_local_endpoint", False 

2205 ) 

2206 ) 

2207 require_local_embeddings = _coerce_bool( 

2208 env_require_local_embeddings 

2209 if env_require_local_embeddings is not None 

2210 else _get_setting_value( 

2211 settings_snapshot, "embeddings.require_local", False 

2212 ) 

2213 ) 

2214 

2215 raw_hostnames = _get_setting_value( 

2216 settings_snapshot, "llm.allowed_local_hostnames", () 

2217 ) 

2218 if isinstance(raw_hostnames, (list, tuple)): 2218 ↛ 2221line 2218 didn't jump to line 2221 because the condition on line 2218 was always true

2219 local_hostnames = tuple(h for h in raw_hostnames if isinstance(h, str)) 

2220 else: 

2221 local_hostnames = () 

2222 

2223 # ADAPTIVE resolves to a concrete scope by classifying the primary 

2224 # engine: a concrete private primary => PRIVATE_ONLY, a concrete public 

2225 # primary => PUBLIC_ONLY, an unclassifiable primary => BOTH. 

2226 # The resolved scope is what the EgressContext stores and what 

2227 # every downstream PEP enforces. Classification reuses classify_engine 

2228 # via a throwaway BOTH-scoped context (its DNS cache is discarded). 

2229 if scope == EgressScope.ADAPTIVE: 

2230 scope = _resolve_adaptive_scope( 

2231 primary_engine, 

2232 settings_snapshot, 

2233 username=username, 

2234 local_hostnames=local_hostnames, 

2235 allow_dns=allow_dns, 

2236 ) 

2237 

2238 # PRIVATE_ONLY means "my data stays on this box." That guarantee only 

2239 # holds if BOTH inference paths are local — a cloud LLM receives the 

2240 # query + retrieved local chunks, and a cloud embedder receives the 

2241 # whole corpus at ingest. So under PRIVATE_ONLY the require_local_* 

2242 # flags are IMPLIED: scope overrides them, so a user who left them at 

2243 # their default (False) can't silently exfiltrate via inference. This 

2244 # also fires when ADAPTIVE resolved to PRIVATE_ONLY above. 

2245 # 

2246 # STRICT is deliberately NOT coupled here: it restricts the search 

2247 # engine set to the primary only and is orthogonal to where inference 

2248 # runs (a user may legitimately want single-engine search + a cloud 

2249 # LLM). Callers that gate on ctx.require_local_* therefore get 

2250 # scope-correct behaviour without a separate flag read. 

2251 if scope == EgressScope.PRIVATE_ONLY: 

2252 require_local_llm = True 

2253 require_local_embeddings = True 

2254 elif scope == EgressScope.UNPROTECTED: 

2255 # The escape hatch lifts user-level locality requirements, but cannot 

2256 # weaken immutable operator environment locks. 

2257 if env_require_local_llm is None: 

2258 require_local_llm = False 

2259 if env_require_local_embeddings is None: 

2260 require_local_embeddings = False 

2261 

2262 return EgressContext( 

2263 scope=scope, 

2264 primary_engine=primary_engine, 

2265 require_local_llm=require_local_llm, 

2266 require_local_embeddings=require_local_embeddings, 

2267 local_hostnames=local_hostnames, 

2268 username=username, 

2269 ) 

2270 

2271 

2272def _get_setting_value(snapshot: dict, key: str, default): 

2273 """Read a setting from the snapshot. 

2274 

2275 Accepts both flat ({key: value}) and nested ({key: {"value": value}}) 

2276 schemas — both shapes appear in LDR's settings infrastructure. 

2277 """ 

2278 raw = snapshot.get(key, default) 

2279 return unwrap_setting(raw) 

2280 

2281 

2282def coerce_str_list(raw) -> tuple[bool, list[str]]: 

2283 """Decode a JSON-list-ish setting value into a list of strings. 

2284 

2285 Returns ``(ok, values)``. Accepts a real list/tuple or a JSON-string list 

2286 and filters to string entries. ``ok`` is False only when a non-empty JSON 

2287 string failed to parse — callers that must *reject* malformed input branch 

2288 on it; fail-safe callers ignore it and use the (empty) list. Single home for 

2289 the decode shared by the trust resolver, the trust banner, and the 

2290 settings-save validators. 

2291 """ 

2292 if isinstance(raw, str): 

2293 if not raw.strip(): 2293 ↛ 2294line 2293 didn't jump to line 2294 because the condition on line 2293 was never true

2294 return True, [] 

2295 try: 

2296 raw = json.loads(raw) 

2297 except Exception: 

2298 return False, [] 

2299 if isinstance(raw, (list, tuple)): 2299 ↛ 2301line 2299 didn't jump to line 2301 because the condition on line 2299 was always true

2300 return True, [x for x in raw if isinstance(x, str)] 

2301 return True, [] 

2302 

2303 

2304def resolve_run_primary_engine( 

2305 settings_snapshot: Optional[dict], default: Optional[str] = None 

2306) -> str: 

2307 """Resolve a research run's primary search engine id from a snapshot. 

2308 

2309 Single source of truth for "which engine drives this run's egress 

2310 scope". Under the default ADAPTIVE scope the concrete scope is derived by 

2311 classifying THIS primary, so run-scoped callers that build an 

2312 ``EgressContext`` should derive the primary here. Otherwise two layers can 

2313 resolve ADAPTIVE to different scopes and one silently under-enforces — the 

2314 LangGraph tool-list filter once derived the primary from the engine CLASS 

2315 name (a collection -> ``"libraryrag"`` -> unclassified -> BOTH) while the 

2316 factory PEP derived it from ``search.tool`` (the real collection key -> 

2317 PRIVATE_ONLY), so a public engine reached the agent and was then 

2318 hard-denied mid-run (``scope_mismatch_private_only``). 

2319 

2320 Reads ``search.tool`` (flat or ``{"value": ...}`` shaped). A blank-after- 

2321 strip (``" "``) or non-string (``5``, list/dict) value is treated as 

2322 MISSING — it must not slip through the truthiness check and classify to the 

2323 permissive BOTH scope. 

2324 

2325 A run with NO configured primary is a wiring error, not a normal state. 

2326 Silently substituting a default would set the egress scope from an engine 

2327 the user never chose — and since the system default is public (``searxng``), 

2328 that fails OPEN. So a missing/empty ``search.tool`` raises ``ValueError`` 

2329 unless the caller passes an explicit ``default``: 

2330 

2331 * run-level callers (research_service, the strategy tool-list filter, 

2332 ``filter_candidates_by_egress``) pass none and fail closed — the worker 

2333 refuses the run; advisory filters degrade to unfiltered with the factory 

2334 PEP still enforcing. 

2335 * the search-engine factory passes ``default=engine_name`` because it is 

2336 evaluating that one specific engine, so deriving the scope from it is 

2337 meaningful rather than arbitrary. 

2338 

2339 Raises: 

2340 ValueError: ``search.tool`` is missing/blank/non-string and no truthy 

2341 ``default`` is given. 

2342 """ 

2343 primary = ( 

2344 _get_setting_value(settings_snapshot, "search.tool", None) 

2345 if settings_snapshot 

2346 else None 

2347 ) 

2348 # Blank-after-strip or non-string is not a usable engine id → missing. 

2349 primary = primary.strip() if isinstance(primary, str) else None 

2350 if primary: 

2351 return primary 

2352 if default: 

2353 return default 

2354 raise ValueError( 

2355 "no primary search engine configured: settings 'search.tool' is " 

2356 "missing, blank, or not a string, so the run's egress scope cannot " 

2357 "be resolved" 

2358 ) 

2359 

2360 

2361def build_run_egress_context( 

2362 settings_snapshot: Optional[dict], 

2363 search_engine: Optional[str], 

2364 *, 

2365 username: Optional[str] = None, 

2366) -> EgressContext: 

2367 """Resolve the research worker's per-run ``EgressContext``. 

2368 

2369 Composes the policy setup ``run_research_process`` runs at startup: 

2370 current operator env policy is reapplied on the snapshot, the primary 

2371 engine is derived, and the ``EgressContext`` is constructed. This is the 

2372 single production seam invoked by the worker; a stale queued snapshot 

2373 carrying an operator-disabled ``unprotected`` scope is coerced to a 

2374 protected scope here (via the read-time backstop in 

2375 ``context_from_snapshot``), not at queue extraction time. 

2376 """ 

2377 from ...settings.manager import ( 

2378 apply_environment_overrides_to_snapshot, 

2379 ) 

2380 

2381 effective = apply_environment_overrides_to_snapshot(settings_snapshot or {}) 

2382 primary = resolve_run_primary_engine({"search.tool": search_engine}) 

2383 return context_from_snapshot(effective, primary, username=username) 

2384 

2385 

2386def _coerce_bool(value) -> bool: 

2387 """Coerce a setting value to bool, defensively. 

2388 

2389 Strings ``"true"`` / ``"True"`` are True; anything else string-shaped 

2390 is False. Real booleans pass through. The strict coercion prevents 

2391 type confusion (e.g., the string ``"false"`` being truthy). 

2392 """ 

2393 if isinstance(value, bool): 

2394 return value 

2395 if isinstance(value, str): 2395 ↛ 2397line 2395 didn't jump to line 2397 because the condition on line 2395 was always true

2396 return value.strip().lower() in ("true", "1", "yes", "on") 

2397 return bool(value)