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

762 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +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 ipaddress 

21import json 

22import socket 

23import threading 

24from collections import OrderedDict 

25from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeout 

26from dataclasses import dataclass, field 

27from enum import Enum 

28from typing import Literal, Optional 

29from urllib.parse import unquote, urlsplit 

30 

31from loguru import logger 

32 

33from ..log_sanitizer import sanitize_for_log 

34from ..network_utils import is_private_ip 

35from ...utilities.type_utils import unwrap_setting 

36 

37 

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

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

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

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

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

43_DNS_TIMEOUT_SEC = 2.0 

44 

45 

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

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

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

49MAX_DENIED_FETCHES_PER_RUN = 50 

50 

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

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

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

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

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

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

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

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

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

60_NON_QUOTA_DENIAL_REASONS = frozenset( 

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

62) 

63 

64 

65class EgressScope(str, Enum): 

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

67 

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

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

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

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

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

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

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

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

76 by ``context_from_snapshot`` and migration 0019. 

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

78 primary behaves as PRIVATE_ONLY, a concrete public primary as 

79 PUBLIC_ONLY, and an unclassifiable primary as BOTH. 

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

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

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

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

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

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

86 """ 

87 

88 STRICT = "strict" 

89 PUBLIC_ONLY = "public_only" 

90 PRIVATE_ONLY = "private_only" 

91 BOTH = "both" 

92 ADAPTIVE = "adaptive" 

93 UNPROTECTED = "unprotected" 

94 

95 

96USER_SELECTABLE_PROTECTED_SCOPES = frozenset( 

97 { 

98 EgressScope.ADAPTIVE, 

99 EgressScope.PUBLIC_ONLY, 

100 EgressScope.PRIVATE_ONLY, 

101 EgressScope.STRICT, 

102 } 

103) 

104 

105 

106def unprotected_egress_allowed() -> bool: 

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

108 from ...settings.env_registry import get_env_setting 

109 

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

111 

112 

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

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

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

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

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

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

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

120# tests/security/test_egress_policy.py:: 

121# test_default_scope_constant_matches_registry. 

122DEFAULT_EGRESS_SCOPE: str = EgressScope.ADAPTIVE.value 

123 

124 

125@dataclass(frozen=True) 

126class EgressContext: 

127 """Frozen per-run policy snapshot. 

128 

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

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

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

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

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

134 """ 

135 

136 scope: EgressScope 

137 primary_engine: str 

138 require_local_llm: bool 

139 require_local_embeddings: bool 

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

141 username: Optional[str] = None 

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

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

144 _fetch_denial_count: dict = field( 

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

146 ) 

147 # Guards _dns_cache + _fetch_denial_count mutations. Scope is the 

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

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

150 _lock: threading.RLock = field( 

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

152 ) 

153 

154 

155@dataclass(frozen=True) 

156class Decision: 

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

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

159 rejected query or URL content. 

160 """ 

161 

162 allowed: bool 

163 reason: str 

164 

165 

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

167class EngineClassification: 

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

169 

170 is_public: bool | None 

171 is_local: bool | None 

172 

173 

174class PolicyDeniedError(RuntimeError): 

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

176 

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

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

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

180 tool call returns. 

181 """ 

182 

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

184 self.decision = decision 

185 self.target = target 

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

187 

188 

189def parse_user_egress_scope( 

190 raw: object, 

191 *, 

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

193) -> EgressScope: 

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

195 

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

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

198 readers can request the protective adaptive fallback for stale queued 

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

200 rejection. 

201 """ 

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

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

204 if not isinstance(raw, str): 

205 raise PolicyDeniedError( 

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

207 ) 

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

209 try: 

210 scope = EgressScope(normalized) 

211 except ValueError as exc: 

212 raise PolicyDeniedError( 

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

214 ) from exc 

215 if scope in USER_SELECTABLE_PROTECTED_SCOPES: 

216 return scope 

217 if scope == EgressScope.UNPROTECTED: 

218 if unprotected_egress_allowed(): 

219 return scope 

220 if disabled_unprotected == "adaptive": 

221 return EgressScope.ADAPTIVE 

222 raise PolicyDeniedError( 

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

224 ) 

225 raise PolicyDeniedError( 

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

227 ) 

228 

229 

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

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

232 

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

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

235 enforcement remains in context_from_snapshot and its PEPs. 

236 """ 

237 try: 

238 return parse_user_egress_scope( 

239 raw, disabled_unprotected="adaptive" 

240 ).value 

241 except PolicyDeniedError: 

242 return DEFAULT_EGRESS_SCOPE 

243 

244 

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

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

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

248 

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

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

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

252 refuse to fetch them. 

253 """ 

254 try: 

255 import ipaddress 

256 

257 from ..ssrf_validator import is_nat64_wrapped_metadata_ip 

258 

259 candidate = hostname 

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

261 candidate = candidate[1:-1] 

262 ip = ipaddress.ip_address(candidate) 

263 return is_nat64_wrapped_metadata_ip(ip) 

264 except (ValueError, TypeError): 

265 return False 

266 except Exception: # pragma: no cover - defensive 

267 return False 

268 

269 

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

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

272 

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

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

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

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

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

278 """ 

279 

280 def _do_lookup(): 

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

282 

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

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

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

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

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

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

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

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

291 future = executor.submit(_do_lookup) 

292 try: 

293 return future.result(timeout=_DNS_TIMEOUT_SEC) 

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

295 return None 

296 except Exception: # pragma: no cover - defensive 

297 return None 

298 finally: 

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

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

301 executor.shutdown(wait=False) 

302 

303 

304_UNCACHED = object() 

305_FAKE_IP_BENCHMARK_NET = ipaddress.ip_network("198.18.0.0/15") 

306 

307 

308def _destination_address(address: str) -> str: 

309 """Return the IPv4 that a wrapped IPv6 literal reaches, else ``address``. 

310 

311 An IPv4-mapped or NAT64 (RFC 6052) literal connects to its embedded IPv4, 

312 so classification follows that payload, not the wrapper prefix, which 

313 ``ipaddress`` rates without looking at the payload (``64:ff9b::/96`` as 

314 public and, in current Python releases, ``64:ff9b:1::/48`` as private). 

315 NAT64 payloads are read with the SSRF validator's ``nat64_embedded_ipv4``, 

316 so both layers decode the same bytes. 

317 """ 

318 from ..ssrf_validator import nat64_embedded_ipv4 

319 

320 try: 

321 parsed = ipaddress.ip_address(address.strip("[]")) 

322 except (ValueError, TypeError, AttributeError): 

323 return address 

324 if not isinstance(parsed, ipaddress.IPv6Address): 

325 return address 

326 embedded = parsed.ipv4_mapped or nat64_embedded_ipv4(parsed) 

327 return address if embedded is None else str(embedded) 

328 

329 

330def _is_ambiguous_fake_ip(address: str) -> bool: 

331 """Recognize addresses that can be real benchmark hosts or proxy fake IPs. 

332 

333 The resolved address alone cannot tell which destination a TUN proxy will 

334 reach. Treating it as private would let a public hostname through a 

335 PRIVATE_ONLY/STRICT gate. IPv4-mapped and NAT64-wrapped (DNS64) forms 

336 have the same ambiguity. 

337 """ 

338 try: 

339 parsed = ipaddress.ip_address(_destination_address(address).strip("[]")) 

340 except (ValueError, TypeError, AttributeError): 

341 return False 

342 return ( 

343 isinstance(parsed, ipaddress.IPv4Address) 

344 and parsed in _FAKE_IP_BENCHMARK_NET 

345 ) 

346 

347 

348def _cache_classification( 

349 ctx: EgressContext, hostname: str, value: Optional[bool] 

350) -> Optional[bool]: 

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

352 

353 Concurrent subagent threads may classify the same hostname outside the 

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

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

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

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

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

359 classification stable and deterministic regardless of completion order. 

360 """ 

361 with ctx._lock: 

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

363 if existing is not _UNCACHED: 

364 return existing 

365 ctx._dns_cache[hostname] = value 

366 return value 

367 

368 

369def _classify_host( 

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

371) -> Optional[bool]: 

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

373 

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

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

376 

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

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

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

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

381 classification is stable for the run even under concurrent disagreeing 

382 lookups. 

383 """ 

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

385 return None 

386 

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

388 # the dict. 

389 with ctx._lock: 

390 if hostname in ctx._dns_cache: 

391 return ctx._dns_cache[hostname] 

392 

393 # User-declared local hostnames override DNS classification. 

394 if hostname in ctx.local_hostnames: 

395 return _cache_classification(ctx, hostname, True) 

396 

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

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

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

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

401 if _is_nat64_wrapped_metadata(hostname): 

402 return _cache_classification(ctx, hostname, False) 

403 

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

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

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

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

408 from ..ssrf_validator import is_ip_blocked 

409 

410 try: 

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

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

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

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

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

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

417 # block_link_local extends the same classification from the literal 

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

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

420 # 

421 # Regression evidence and local-backend controls: 

422 # tests/security/test_llm_endpoint_link_local_hardening.py 

423 # - test_link_local_does_not_classify_as_local_in_egress_policy 

424 # - test_self_hosted_still_classifies_as_local 

425 # 

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

427 # there and must keep working. 

428 if is_ip_blocked( 

429 hostname, 

430 allow_localhost=True, 

431 allow_private_ips=True, 

432 block_link_local=True, 

433 ): 

434 return _cache_classification(ctx, hostname, False) 

435 # An IPv4-mapped or NAT64 literal that passed is_ip_blocked is 

436 # classified by its embedded IPv4, like a direct connection to it. 

437 if _is_ambiguous_fake_ip(hostname): 

438 return _cache_classification(ctx, hostname, None) 

439 if is_private_ip(_destination_address(hostname)): 

440 return _cache_classification(ctx, hostname, True) 

441 except Exception as exc: 

442 logger.debug( 

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

444 error=str(exc), 

445 ) 

446 

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

448 if not allow_dns: 

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

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

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

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

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

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

455 return None 

456 addr_info = _resolve_with_timeout(hostname) 

457 if addr_info is None: 

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

459 return _cache_classification(ctx, hostname, False) 

460 

461 # A hostname is local only when every resolved address is local. Inspect 

462 # every ordinary answer: a fake IP keeps the destination ambiguous even 

463 # alongside a public address, regardless of the resolver's answer order. 

464 # Metadata answers still prevent a local classification immediately. 

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

466 return _cache_classification(ctx, hostname, False) 

467 ambiguous_address = False 

468 public_address = False 

469 for entry in addr_info: 

470 try: 

471 ip_str = entry[4][0] 

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

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

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

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

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

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

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

479 if is_ip_blocked( 

480 ip_str, 

481 allow_localhost=True, 

482 allow_private_ips=True, 

483 block_link_local=True, 

484 ): 

485 return _cache_classification(ctx, hostname, False) 

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

487 return _cache_classification(ctx, hostname, False) 

488 if _is_ambiguous_fake_ip(ip_str): 

489 ambiguous_address = True 

490 continue 

491 if not is_private_ip(_destination_address(ip_str)): 

492 public_address = True 

493 except Exception: # pragma: no cover - defensive 

494 return _cache_classification(ctx, hostname, False) 

495 return _cache_classification( 

496 ctx, hostname, None if ambiguous_address else not public_address 

497 ) 

498 

499 

500def _get_engine_class(engine_name: str): 

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

502 # Import is lazy to break the dependency cycle: 

503 # security/* → engines/* → security/* 

504 from ...web_search_engines.engine_registry import ENGINE_REGISTRY 

505 from ..module_whitelist import get_safe_module_class 

506 

507 entry = ENGINE_REGISTRY.get(engine_name) 

508 if entry is None: 

509 return None 

510 try: 

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

512 except Exception: 

513 return None 

514 

515 

516def _engine_flags(engine_cls): 

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

518 

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

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

521 to ``False``. 

522 """ 

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

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

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

526 return is_public, is_local, url_setting 

527 

528 

529def _resolve_collection_is_public( 

530 engine_name: str, username: Optional[str] 

531) -> bool: 

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

533 

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

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

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

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

538 """ 

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

540 return False 

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

542 try: 

543 from ...database.models.library import Collection 

544 from ...database.session_context import get_user_db_session 

545 

546 with get_user_db_session(username) as session: 

547 row = ( 

548 session.query(Collection) 

549 .filter(Collection.id == collection_id) 

550 .first() 

551 ) 

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

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

554 logger.debug( 

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

556 engine=engine_name, 

557 ) 

558 return False 

559 

560 

561# --------------------------------------------------------------------------- 

562# Vector-store (RAG sink) classification 

563# --------------------------------------------------------------------------- 

564 

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

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

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

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

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

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

571# is treated as REMOTE, not local. 

572_LOCAL_FILE_VECTOR_STORE_PROVIDERS = frozenset({"faiss"}) 

573 

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

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

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

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

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

579# 

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

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

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

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

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

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

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

587 

588 

589def _vector_store_class(provider: str): 

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

591 

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

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

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

595 """ 

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

597 return None 

598 try: 

599 from ...vector_stores.config import get_vector_store_class 

600 

601 return get_vector_store_class(provider) 

602 except Exception: 

603 logger.debug( 

604 "vector-store provider class unavailable; " 

605 "classifying from the provider key alone", 

606 provider=sanitize_for_log(provider), 

607 ) 

608 return None 

609 

610 

611def _normalize_provider_key(raw) -> str: 

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

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

614 return "" 

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

616 

617 

618def _resolve_index_vector_store_providers( 

619 engine_name: str, username: Optional[str] 

620) -> set: 

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

622 

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

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

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

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

627 

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

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

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

631 exposure comes from settings regardless. 

632 """ 

633 try: 

634 from ...database.models.library import RAGIndex 

635 from ...database.session_context import get_user_db_session 

636 

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

638 if column is None: 

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

640 with get_user_db_session(username) as session: 

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

642 if engine_name != "library": 

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

644 return { 

645 key 

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

647 if key 

648 } 

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

650 logger.debug( 

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

652 engine=sanitize_for_log(engine_name), 

653 ) 

654 return set() 

655 

656 

657def _resolve_vector_store_providers( 

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

659) -> set: 

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

661 

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

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

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

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

666 (contained) status quo. 

667 """ 

668 providers = set() 

669 try: 

670 from ...vector_stores.config import resolve_provider 

671 

672 key = _normalize_provider_key(resolve_provider(settings_snapshot)) 

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

674 providers.add(key) 

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

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

677 providers |= _resolve_index_vector_store_providers( 

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

679 ) 

680 return providers 

681 

682 

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

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

685 

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

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

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

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

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

691 """ 

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

693 

694 

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

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

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

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

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

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

701# a tab in the MIDDLE of a string. 

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

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

704 

705 

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

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

708 

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

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

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

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

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

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

715 URL parser: 

716 

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

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

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

720 ``attacker.example.com:19530``. 

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

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

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

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

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

726 lstrips it away. 

727 

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

729 from drifting apart again. 

730 

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

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

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

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

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

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

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

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

739 ``_file_uri_names_a_remote_share``: without it, 

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

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

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

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

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

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

746 

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

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

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

750 extra trim cannot make a remote endpoint look local. 

751 """ 

752 normalized = value.lstrip(_URL_PARSER_LEADING_STRIP) 

753 for char in _URL_PARSER_REMOVED_CHARS: 

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

755 return normalized.strip() 

756 

757 

758def _is_local_filesystem_uri(value) -> bool: 

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

760 if not isinstance(value, str): 

761 return False 

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

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

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

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

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

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

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

769 uri = _normalize_uri_like_url_parser(value) 

770 if not uri: 

771 return False 

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

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

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

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

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

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

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

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

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

781 # 

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

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

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

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

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

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

788 # ``_vector_store_endpoint_is_local`` -> ``vector_store_is_contained`` 

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

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

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

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

793 try: 

794 parsed = urlsplit(uri) 

795 except Exception: 

796 return False 

797 netloc = parsed.netloc 

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

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

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

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

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

803 # the bottom of this function. 

804 is_drive_letter_netloc = ( 

805 len(netloc) == 2 

806 and netloc[0].isascii() 

807 and netloc[0].isalpha() 

808 and netloc[1] == ":" 

809 ) 

810 if not ( 

811 netloc == "" 

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

813 or is_drive_letter_netloc 

814 ): 

815 return False 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

836 # 

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

838 # of ``_file_uri_names_a_remote_share``, which 

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

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

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

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

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

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

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

846 # (``test_file_uri_percent_encoded_unc_in_path_is_not_a_local_file``). 

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

848 if uri.startswith(_LOCAL_PATH_URI_PREFIXES): 

849 return True 

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

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

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

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

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

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

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

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

858 return True 

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

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

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

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

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

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

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

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

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

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

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

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

871 # 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

888 return ( 

889 len(uri) > 2 

890 and uri[0].isascii() 

891 and uri[0].isalpha() 

892 and uri[1] == ":" 

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

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

895 ) 

896 

897 

898def _file_uri_names_a_remote_share(value) -> bool: 

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

900 

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

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

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

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

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

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

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

908 authority. 

909 

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

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

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

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

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

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

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

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

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

919 stepped around by such a value. 

920 

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

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

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

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

925 bidirectional and additionally removes the 19 leading Unicode-whitespace 

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

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

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

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

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

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

932 CONTAINED. Normalise first; see 

933 ``test_leading_unicode_whitespace_cannot_hide_a_network_share``. 

934 

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

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

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

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

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

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

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

942 

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

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

945 itself local. 

946 """ 

947 if not isinstance(value, str): 

948 return False 

949 uri = _normalize_uri_like_url_parser(value) 

950 try: 

951 parsed = urlsplit(uri) 

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

953 return False 

954 return _starts_with_double_separator(unquote(parsed.path)) 

955 except Exception: 

956 return True 

957 

958 

959def _vector_store_endpoint_is_local( 

960 provider: str, 

961 settings_snapshot: Optional[dict], 

962 ctx: EgressContext, 

963 allow_dns: bool = True, 

964) -> bool: 

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

966 

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

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

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

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

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

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

973 """ 

974 cls = _vector_store_class(provider) 

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

976 if not uri_setting: 

977 return False 

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

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

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

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

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

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

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

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

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

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

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

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

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

991 # 

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

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

994 # ``(list, tuple)``) and the default "skip non-``str`` entries" rule. 

995 # Elasticsearch's dictionary-host opt-in must remain disabled here. 

996 # If one side alone grows tuple support, a tuple-shaped setting reaches 

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

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

999 # neither. 

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

1001 if _file_uri_names_a_remote_share(entry): 

1002 return False 

1003 if _is_local_filesystem_uri(uri): 

1004 return True 

1005 return ( 

1006 _classify_engine_url( 

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

1008 ) 

1009 is True 

1010 ) 

1011 

1012 

1013def vector_store_is_contained( 

1014 engine_name: str, 

1015 ctx: EgressContext, 

1016 settings_snapshot: Optional[dict], 

1017 allow_dns: bool = True, 

1018) -> bool: 

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

1020 

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

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

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

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

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

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

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

1028 ``url_setting``. 

1029 

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

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

1032 diverge. 

1033 """ 

1034 for provider in _resolve_vector_store_providers( 

1035 engine_name, ctx, settings_snapshot 

1036 ): 

1037 cls = _vector_store_class(provider) 

1038 if cls is not None: 

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

1040 continue 

1041 elif provider in _LOCAL_FILE_VECTOR_STORE_PROVIDERS: 

1042 continue 

1043 if not _vector_store_endpoint_is_local( 

1044 provider, settings_snapshot, ctx, allow_dns=allow_dns 

1045 ): 

1046 return False 

1047 return True 

1048 

1049 

1050def classify_engine( 

1051 engine_name: str, 

1052 ctx: EgressContext, 

1053 *, 

1054 settings_snapshot: dict, 

1055 metadata: Optional[dict] = None, 

1056 allow_dns: bool = True, 

1057) -> EngineClassification: 

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

1059 

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

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

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

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

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

1065 a public engine. 

1066 

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

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

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

1070 while an ambiguous configured endpoint is ineligible under protected 

1071 scopes. A public-nature engine on a local URL stays public. 

1072 

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

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

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

1076 direct DB lookup), defaulting private. 

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

1078 """ 

1079 engine_cls = _get_engine_class(engine_name) 

1080 if engine_cls is None: 

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

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

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

1084 else: 

1085 is_public = _resolve_collection_is_public( 

1086 engine_name, ctx.username 

1087 ) 

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

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

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

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

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

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

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

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

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

1097 # public collection from private runs. 

1098 # 

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

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

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

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

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

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

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

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

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

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

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

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

1111 # become newly eligible under PUBLIC_ONLY just because its store 

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

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

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

1115 if not vector_store_is_contained( 

1116 engine_name, ctx, settings_snapshot, allow_dns=allow_dns 

1117 ): 

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

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

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

1121 is_public, is_local, url_setting = _engine_flags(engine_cls) 

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

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

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

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

1126 # at selection time (matching pre-static-flags behavior). An ambiguous 

1127 # configured URL is ineligible under protected scopes. The reverse 

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

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

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

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

1132 host_classification = _classify_engine_url( 

1133 url_setting, 

1134 settings_snapshot, 

1135 ctx, 

1136 allow_dns=allow_dns, 

1137 allow_host_dicts=engine_name == "elasticsearch", 

1138 ) 

1139 if host_classification is False: 

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

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

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

1143 if ( 

1144 host_classification is None 

1145 and allow_dns 

1146 and _get_setting_value(settings_snapshot, url_setting, None) 

1147 ): 

1148 # A configured endpoint in the fake-IP benchmark range could be 

1149 # a local benchmark service or a public site behind a TUN proxy. 

1150 # Neither eligibility is safe to infer. Deny this engine in 

1151 # protected scopes while Adaptive retains its restrictive 

1152 # PRIVATE_ONLY fallback for a non-contained private primary. 

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

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

1155 

1156 

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

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

1159_PREFILTER_STRIP_REASONS = frozenset( 

1160 { 

1161 "scope_mismatch_public_only", 

1162 "scope_mismatch_private_only", 

1163 "strict_not_primary", 

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

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

1166 # enforcement exactly. 

1167 "unclassified", 

1168 } 

1169) 

1170 

1171 

1172def filter_engines_by_egress( 

1173 engine_names: list[str], 

1174 ctx: EgressContext, 

1175 settings_snapshot: dict, 

1176) -> list[str]: 

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

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

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

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

1181 because it queries the internet. 

1182 

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

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

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

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

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

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

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

1190 silently hide legitimate engines from LLM selection. The factory 

1191 still denies anything truly disallowed at instantiation. 

1192 

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

1194 selection set before presenting it to an LLM. 

1195 """ 

1196 allowed: list[str] = [] 

1197 for name in engine_names: 

1198 decision = evaluate_engine( 

1199 name, ctx, settings_snapshot=settings_snapshot 

1200 ) 

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

1202 allowed.append(name) 

1203 return allowed 

1204 

1205 

1206def filter_candidates_by_egress( 

1207 engine_names: list[str], 

1208 settings_snapshot: Optional[dict], 

1209) -> list[str]: 

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

1211 

1212 Wraps ``filter_engines_by_egress`` with the snapshot plumbing 

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

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

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

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

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

1218 helper must never break engine selection. 

1219 """ 

1220 if not settings_snapshot: 

1221 return engine_names 

1222 try: 

1223 scope_raw = str( 

1224 _get_setting_value( 

1225 settings_snapshot, "policy.egress_scope", DEFAULT_EGRESS_SCOPE 

1226 ) 

1227 or DEFAULT_EGRESS_SCOPE 

1228 ).lower() 

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

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

1231 if scope_raw not in ( 

1232 "private_only", 

1233 "public_only", 

1234 "strict", 

1235 "adaptive", 

1236 ): 

1237 return engine_names 

1238 primary = resolve_run_primary_engine(settings_snapshot) 

1239 ctx = context_from_snapshot( 

1240 settings_snapshot, 

1241 primary, 

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

1243 ) 

1244 return filter_engines_by_egress(engine_names, ctx, settings_snapshot) 

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

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

1247 "egress candidate pre-filter failed; " 

1248 "factory PEP still enforces at child instantiation" 

1249 ) 

1250 return engine_names 

1251 

1252 

1253def evaluate_engine( 

1254 engine_name: str, 

1255 ctx: EgressContext, 

1256 *, 

1257 settings_snapshot: dict, 

1258 metadata: Optional[dict] = None, 

1259) -> Decision: 

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

1261 

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

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

1264 Unclassified engines always fail closed. 

1265 

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

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

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

1269 """ 

1270 if settings_snapshot is None: 

1271 return Decision(False, "no_snapshot") 

1272 try: 

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

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

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

1276 if ctx.scope == EgressScope.UNPROTECTED: 

1277 return Decision(True, "egress_unprotected") 

1278 

1279 # STRICT: only the primary engine is permitted. 

1280 if ( 

1281 ctx.scope == EgressScope.STRICT 

1282 and engine_name != ctx.primary_engine 

1283 ): 

1284 return Decision(False, "strict_not_primary") 

1285 

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

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

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

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

1290 "collection_" 

1291 ) 

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

1293 return Decision(False, "engine_unknown") 

1294 

1295 classification = classify_engine( 

1296 engine_name, 

1297 ctx, 

1298 settings_snapshot=settings_snapshot, 

1299 metadata=metadata, 

1300 ) 

1301 

1302 # Fail-closed for unclassified engines. 

1303 if ( 

1304 classification.is_public is not True 

1305 and classification.is_local is not True 

1306 ): 

1307 return Decision(False, "unclassified") 

1308 

1309 # Scope/bucket compatibility. 

1310 if ( 

1311 ctx.scope == EgressScope.PUBLIC_ONLY 

1312 and classification.is_public is not True 

1313 ): 

1314 return Decision(False, "scope_mismatch_public_only") 

1315 if ( 

1316 ctx.scope == EgressScope.PRIVATE_ONLY 

1317 and classification.is_local is not True 

1318 ): 

1319 return Decision(False, "scope_mismatch_private_only") 

1320 

1321 return Decision(True, "allowed") 

1322 except Exception: # pragma: no cover - defensive 

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

1324 "evaluate_engine internal error", engine=engine_name 

1325 ) 

1326 return Decision(False, "internal_error") 

1327 

1328 

1329def _urllib3_reads_same_host(url: str, hostname: str) -> bool: 

1330 """Whether urllib3's ``parse_url`` finds ``hostname`` in ``url`` too. 

1331 

1332 The Elasticsearch client parses host URLs with it, while this module uses 

1333 ``urlsplit``, and the two can disagree. For ``http://a:9200\\@b:9200`` 

1334 ``urlsplit`` reads ``b`` but urllib3 reads ``a``. urllib3 also 

1335 IDNA-encodes a non-ASCII name, which the resolver can encode differently. 

1336 Brackets and letter case are the only differences ignored. 

1337 """ 

1338 from urllib3.util import parse_url 

1339 

1340 try: 

1341 host = parse_url(url).host 

1342 except Exception: 

1343 return False 

1344 return ( 

1345 isinstance(host, str) and host.strip("[]").lower() == hostname.lower() 

1346 ) 

1347 

1348 

1349def _classify_engine_url( 

1350 url_setting: str, 

1351 settings_snapshot: dict, 

1352 ctx: EgressContext, 

1353 allow_dns: bool = True, 

1354 *, 

1355 allow_host_dicts: bool = False, 

1356) -> Optional[bool]: 

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

1358 

1359 For list-typed settings (Elasticsearch ``hosts``), an unknown entry 

1360 prevents either classification because the client may select it. With 

1361 no unknown entries, a public entry wins over local entries. Returns True 

1362 if wholly local, False if public, None if undetermined. 

1363 

1364 Elasticsearch also accepts dictionaries with a ``host`` field. Only its 

1365 engine opts in (``allow_host_dicts``). The opt-in also splits a string 

1366 value into the nodes the engine connects to, and treats as unknown a URL 

1367 whose host the client's parser reads differently, and a URL or dictionary 

1368 host that is not ASCII (the resolver and the client encode such a name 

1369 differently) or contains ``%`` (HTTP client normalization is 

1370 version-dependent). Other consumers keep single URL-string parsing 

1371 and percent-decoding. 

1372 """ 

1373 value = _get_setting_value(settings_snapshot, url_setting, None) 

1374 if not value: 

1375 return None 

1376 

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

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

1379 # test (``isinstance(..., list)``, not ``(list, tuple)``) and the default 

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

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

1382 # never examined. Dictionary hosts and the string splitting below are an 

1383 # Elasticsearch-only opt-in; vector-store URI classification must not 

1384 # enable it. 

1385 if allow_host_dicts and isinstance(value, str): 

1386 # The engine splits a string value with ``_ensure_list`` (a JSON 

1387 # list, else comma-separated nodes; a non-JSON env override or form 

1388 # value stays a string) and connects to every node. Parsing the 

1389 # whole string as one URL would judge only its first host. 

1390 from ...web_search_engines.engines.search_engine_elasticsearch import ( 

1391 ElasticsearchSearchEngine, 

1392 ) 

1393 

1394 entries = ElasticsearchSearchEngine._ensure_list(value) 

1395 else: 

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

1397 any_public = False 

1398 any_local = False 

1399 any_unknown = False 

1400 for entry in entries: 

1401 if allow_host_dicts and isinstance(entry, dict): 

1402 # The SDK uses the bare host, not a URL: retain IPv6 colons and 

1403 # never reinterpret a path, userinfo, or query as an authority. 

1404 hostname = entry.get("host") 

1405 if not isinstance(hostname, str) or not hostname: 

1406 any_unknown = True 

1407 continue 

1408 hostname = hostname.lower().rstrip(".") 

1409 if not hostname or any( 

1410 char.isspace() or ord(char) < 32 or char in "/?#@[]" 

1411 for char in hostname 

1412 ): 

1413 any_unknown = True 

1414 continue 

1415 elif isinstance(entry, str): 

1416 try: 

1417 if "://" in entry: 

1418 to_parse = entry 

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

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

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

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

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

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

1425 # classification. Prepend only the scheme so the leading 

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

1427 # authority marker. 

1428 to_parse = f"http:{entry}" 

1429 else: 

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

1431 parsed = urlsplit(to_parse) 

1432 hostname = parsed.hostname 

1433 except Exception: 

1434 if allow_host_dicts: 

1435 any_unknown = True 

1436 continue 

1437 if not hostname: 

1438 if allow_host_dicts: 1438 ↛ 1439line 1438 didn't jump to line 1439 because the condition on line 1438 was never true

1439 any_unknown = True 

1440 continue 

1441 if allow_host_dicts and not _urllib3_reads_same_host( 

1442 to_parse, hostname 

1443 ): 

1444 # The Elasticsearch client connects to the host urllib3 

1445 # reads, so a different one here was never classified. 

1446 any_unknown = True 

1447 continue 

1448 if not allow_host_dicts: 

1449 # Other consumers decode, as evaluate_url does: requests 

1450 # percent-decodes the host before connecting, so 

1451 # "192%2e168%2e1%2e1" is 192.168.1.1. Elasticsearch's HTTP 

1452 # client has varied across versions, so percent-bearing 

1453 # hosts are withheld below. 

1454 hostname = unquote(hostname) 

1455 else: 

1456 if allow_host_dicts: 1456 ↛ 1457line 1456 didn't jump to line 1457 because the condition on line 1456 was never true

1457 any_unknown = True 

1458 continue 

1459 if allow_host_dicts and (not hostname.isascii() or "%" in hostname): 

1460 # Both branches meet here, so a dictionary host and a URL host are 

1461 # judged alike, as written. urllib3 2.8 decodes unreserved host 

1462 # escapes that 2.7 passed to DNS unchanged. Keep percent-bearing 

1463 # hosts unknown under either behavior, including scoped IPv6 

1464 # literals such as ``fe80::1%eth0``. The resolver also encodes a 

1465 # non-ASCII name with IDNA 2003 (``faß`` is looked up as 

1466 # ``fass``) but the client's urllib3 uses IDNA 2008 

1467 # (``xn--fa-hia``). Either way the name classified here need not 

1468 # be the one the client connects to. An ``xn--`` spelling is 

1469 # plain ASCII and is classified as written. 

1470 any_unknown = True 

1471 continue 

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

1473 if result is True: 

1474 any_local = True 

1475 elif result is False: 

1476 any_public = True 

1477 else: 

1478 any_unknown = True 

1479 

1480 if any_unknown: 

1481 return None # an ambiguous endpoint can still be selected 

1482 if any_public: 

1483 return False # public wins over local under list-typed settings 

1484 if any_local: 

1485 return True 

1486 return None 

1487 

1488 

1489_CLOUD_LLM_PROVIDERS = frozenset( 

1490 { 

1491 "openai", 

1492 "anthropic", 

1493 "google", 

1494 "openrouter", 

1495 "requesty", 

1496 "orcarouter", 

1497 "atlascloud", 

1498 "qwen", 

1499 "deepseek", 

1500 "xai", 

1501 "ionos", 

1502 } 

1503) 

1504 

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

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

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

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

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

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

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

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

1513_LOCAL_DEFAULT_LLM_PROVIDERS = frozenset( 

1514 { 

1515 "ollama", 

1516 "lmstudio", 

1517 "llamacpp", 

1518 } 

1519) 

1520 

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

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

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

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

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

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

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

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

1529_LOCAL_DEFAULT_EMBEDDING_PROVIDERS = frozenset( 

1530 { 

1531 "sentence_transformers", 

1532 "ollama", 

1533 } 

1534) 

1535 

1536 

1537def _is_user_registered_llm( 

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

1539) -> bool: 

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

1541 

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

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

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

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

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

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

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

1549 operator injected them deliberately at code level, and under 

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

1551 outbound socket they might open. 

1552 

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

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

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

1556 

1557 Fails closed (False) on any error. 

1558 """ 

1559 try: 

1560 from ...llm.llm_registry import is_llm_registered 

1561 from ...llm.providers import discover_providers 

1562 from ...llm.providers.base import normalize_provider 

1563 

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

1565 return False 

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

1567 return normalize_provider(provider) not in discovered 

1568 except Exception: # pragma: no cover - defensive 

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

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

1571 ) 

1572 return False 

1573 

1574 

1575def evaluate_llm_endpoint( 

1576 provider: str, 

1577 ctx: EgressContext, 

1578 *, 

1579 settings_snapshot: dict, 

1580 username: Optional[str] = None, 

1581) -> Decision: 

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

1583 

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

1585 

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

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

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

1589 """ 

1590 if username is None: 

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

1592 if settings_snapshot is None: 

1593 return Decision(False, "no_snapshot") 

1594 try: 

1595 if not ctx.require_local_llm: 

1596 return Decision(True, "no_local_requirement") 

1597 

1598 # Hard-cloud providers always fail under require_local_llm. 

1599 if provider in _CLOUD_LLM_PROVIDERS: 

1600 return Decision(False, "provider_cloud_only") 

1601 

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

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

1604 # backstops stray sockets under PRIVATE_ONLY/STRICT. 

1605 if _is_user_registered_llm(provider, username=username): 

1606 return Decision(True, "user_registered_llm") 

1607 

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

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

1610 url_value = _get_setting_value(settings_snapshot, url_key, None) 

1611 if not url_value: 

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

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

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

1615 # allow-list. 

1616 if provider in _LOCAL_DEFAULT_LLM_PROVIDERS: 

1617 return Decision(True, "provider_local_default") 

1618 return Decision(False, "provider_url_unset") 

1619 

1620 try: 

1621 parsed = urlsplit(url_value) 

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

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

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

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

1626 # local endpoint is wrongly classified public and denied. 

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

1628 except Exception: 

1629 return Decision(False, "url_malformed") 

1630 

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

1632 if classification is True: 

1633 return Decision(True, "provider_local") 

1634 return Decision(False, "provider_remote") 

1635 except Exception: # pragma: no cover - defensive 

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

1637 "evaluate_llm_endpoint internal error", provider=provider 

1638 ) 

1639 return Decision(False, "internal_error") 

1640 

1641 

1642def evaluate_embeddings( 

1643 provider: str, 

1644 ctx: EgressContext, 

1645 *, 

1646 settings_snapshot: dict, 

1647) -> Decision: 

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

1649 

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

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

1652 is configured to a local host. 

1653 """ 

1654 if settings_snapshot is None: 

1655 return Decision(False, "no_snapshot") 

1656 try: 

1657 if not ctx.require_local_embeddings: 

1658 return Decision(True, "no_local_requirement") 

1659 

1660 if provider == "sentence_transformers": 

1661 return Decision(True, "provider_local") 

1662 

1663 if provider == "ollama": 

1664 url = _get_setting_value( 

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

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

1667 if not url: 

1668 return Decision(True, "provider_local_default") 

1669 parsed = urlsplit(url) 

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

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

1672 return ( 

1673 Decision(True, "provider_local") 

1674 if classification is True 

1675 else Decision(False, "provider_remote") 

1676 ) 

1677 

1678 if provider == "openai": 

1679 base_url = _get_setting_value( 

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

1681 ) 

1682 if base_url: 

1683 parsed = urlsplit(base_url) 

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

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

1686 if classification is True: 

1687 return Decision(True, "provider_local_endpoint") 

1688 return Decision(False, "provider_cloud") 

1689 

1690 return Decision(False, "provider_unknown") 

1691 except Exception: # pragma: no cover - defensive 

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

1693 "evaluate_embeddings internal error", provider=provider 

1694 ) 

1695 return Decision(False, "internal_error") 

1696 

1697 

1698_DANGEROUS_SCHEMES = frozenset( 

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

1700) 

1701 

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

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

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

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

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

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

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

1709 

1710 

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

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

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

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

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

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

1717 

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

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

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

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

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

1723 """ 

1724 try: 

1725 import socket 

1726 

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

1728 except (OSError, UnicodeError, TypeError): 

1729 return None 

1730 

1731 

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

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

1734 quota. 

1735 

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

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

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

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

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

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

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

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

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

1745 isolated calls self-contained. 

1746 """ 

1747 try: 

1748 from .audit_hook import get_active_context 

1749 

1750 active = get_active_context() 

1751 except Exception: # pragma: no cover - defensive 

1752 active = None 

1753 return active if active is not None else ctx 

1754 

1755 

1756def evaluate_url( 

1757 url: str, ctx: EgressContext, *, record_denial: bool = True 

1758) -> Decision: 

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

1760 

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

1762 via malicious indexed documents that loop the agent through hundreds 

1763 of denied fetches. 

1764 

1765 ``record_denial=False`` returns the same decision without recording a 

1766 denial: it is neither counted toward that quota nor written to the 

1767 audit log (an internal error is still logged). Only for a caller that 

1768 fetches nothing on the decision, such as one that ranks URLs; a fetch 

1769 must be decided with the default, so that its denials are counted. 

1770 """ 

1771 deny = _record_denial if record_denial else _unrecorded_denial 

1772 try: 

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

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

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

1776 # concurrent subagents see a consistent count. The check itself never 

1777 # records, so probes and fetches share the same fail-closed cliff. 

1778 qctx = _quota_ctx(ctx) 

1779 with qctx._lock: 

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

1781 return Decision(False, "denial_quota_exceeded") 

1782 

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

1784 return deny(ctx, url, "url_malformed") 

1785 

1786 try: 

1787 parsed = urlsplit(url) 

1788 except Exception: 

1789 return deny(ctx, url, "url_malformed") 

1790 

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

1792 return deny(ctx, url, "dangerous_scheme") 

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

1794 return deny(ctx, url, "unsupported_scheme") 

1795 if not parsed.hostname: 

1796 return deny(ctx, url, "no_hostname") 

1797 

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

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

1800 # preserves the encoding. Classifying the encoded form lets 

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

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

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

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

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

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

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

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

1809 # rstrip("."). Shared with ``host_not_classified_public`` via 

1810 # ``_policy_host`` so both classify the same host. 

1811 host = _policy_host(parsed.hostname) 

1812 

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

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

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

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

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

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

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

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

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

1822 # IPv4-mapped IPv6 forms. 

1823 try: 

1824 from ..ssrf_validator import is_ip_blocked 

1825 

1826 if is_ip_blocked( 

1827 host, allow_localhost=True, allow_private_ips=True 

1828 ): 

1829 return deny(ctx, url, "blocked_metadata_ip") 

1830 # is_ip_blocked only parses canonical IP notation, so an 

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

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

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

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

1835 # too — and they get the explicit blocked_metadata_ip reason 

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

1837 alt = _normalize_alt_ipv4(host) 

1838 if ( 

1839 alt is not None 

1840 and alt != host 

1841 and is_ip_blocked( 

1842 alt, allow_localhost=True, allow_private_ips=True 

1843 ) 

1844 ): 

1845 return deny(ctx, url, "blocked_metadata_ip") 

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

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

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

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

1850 # not depend on it). 

1851 if host.lower() in _METADATA_HOSTNAMES: 

1852 return deny(ctx, url, "blocked_metadata_ip") 

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

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

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

1856 pass 

1857 

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

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

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

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

1862 if ctx.scope == EgressScope.UNPROTECTED: 

1863 return Decision(True, "egress_unprotected") 

1864 

1865 classification = _classify_host(host, ctx) 

1866 

1867 if ctx.scope == EgressScope.STRICT: 

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

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

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

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

1872 if classification is True: 

1873 return Decision(True, "allowed_private_host_under_strict") 

1874 return deny(ctx, url, "strict_public_host") 

1875 

1876 if ctx.scope == EgressScope.PUBLIC_ONLY: 

1877 if classification is False: 

1878 return Decision(True, "allowed_public_host") 

1879 return deny(ctx, url, "scope_mismatch_public_only") 

1880 

1881 if ctx.scope == EgressScope.PRIVATE_ONLY: 

1882 if classification is True: 

1883 return Decision(True, "allowed_private_host") 

1884 return deny(ctx, url, "scope_mismatch_private_only") 

1885 

1886 # BOTH: any classified host is fine. 

1887 if classification is None: 

1888 return deny(ctx, url, "host_unclassified") 

1889 return Decision(True, "allowed_both_scope") 

1890 except Exception: # pragma: no cover - defensive 

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

1892 return Decision(False, "internal_error") 

1893 

1894 

1895def _unrecorded_denial( 

1896 ctx: EgressContext, url: object, reason: str 

1897) -> Decision: 

1898 """A denial for ``evaluate_url(record_denial=False)``. 

1899 

1900 Neither counted toward the denied-fetch quota nor logged. 

1901 """ 

1902 return Decision(False, reason) 

1903 

1904 

1905def host_not_classified_public(url: str, ctx: EgressContext) -> bool: 

1906 """True when ``url`` is an http(s) URL whose host the policy does NOT 

1907 classify public (private, or unclassifiable), decided WITHOUT recording 

1908 a denial (nothing counts toward ``MAX_DENIED_FETCHES_PER_RUN``). 

1909 

1910 Independent of ``ctx.scope``: the caller decides which scopes refuse 

1911 such hosts. Lets a caller that refuses private destinations as a matter 

1912 of course (the full-content search wrapper, whose result URLs come from 

1913 an index it does not control, under every scope but ``unprotected``) 

1914 drop them before ``evaluate_url`` without each one spending the run's 

1915 quota. It uses the SAME host normalisation and the SAME 

1916 ``_classify_host`` (and run-scoped DNS cache) as ``evaluate_url``, so 

1917 the set it returns True for is exactly the set ``evaluate_url`` refuses 

1918 as ``scope_mismatch_public_only`` under ``public_only``, not a second, 

1919 differently defined list. (The SSRF validator's ``PRIVATE_IP_RANGES`` 

1920 is narrower: it admits 198.18.0.0/15, the TEST-NETs, 192.0.0.0/24, 

1921 240.0.0.0/4 and 2001:db8::/32, and with NAT64 allowed any NAT64 address 

1922 whose embedded IPv4 it admits. The 198.18.0.0/15 addresses (including 

1923 fake-IP DNS answers) are unclassified here; the other listed ranges 

1924 classify private. IPv4-mapped and NAT64 addresses classify as their 

1925 embedded IPv4.) 

1926 

1927 Returns False for anything ``evaluate_url`` refuses for another reason 

1928 first (malformed URL, non-http(s) scheme, no host); a cloud-metadata 

1929 host classifies public, so it is left to ``evaluate_url``'s 

1930 ``blocked_metadata_ip`` denial. An internal error returns True (fails 

1931 toward refusal, which records no denial either). 

1932 """ 

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

1934 return False 

1935 try: 

1936 parsed = urlsplit(url) 

1937 hostname = parsed.hostname 

1938 except Exception: # noqa: silent-exception - malformed, see docstring 

1939 return False 

1940 if parsed.scheme.lower() not in ("http", "https") or not hostname: 

1941 return False 

1942 try: 

1943 return _classify_host(_policy_host(hostname), ctx) is not False 

1944 except Exception: # noqa: silent-exception - fail toward refusal 

1945 return True 

1946 

1947 

1948def _policy_host(hostname: str) -> str: 

1949 """The host ``evaluate_url`` classifies: percent-decoded (HTTP clients 

1950 decode it before connecting) and without trailing dots (insignificant 

1951 to the resolver); see the comments at its call site there.""" 

1952 return unquote(hostname).rstrip(".") 

1953 

1954 

1955def url_host_is_declared_local(url: object, ctx: EgressContext) -> bool: 

1956 """Whether the URL host has the run's operator-declared local status. 

1957 

1958 The declared name must retain its classification when DNS pinning checks 

1959 the connect-time address; an address-only URL would lose that override. 

1960 Apply the same hostname normalization as ``evaluate_url``. 

1961 """ 

1962 if not ctx.local_hostnames or not isinstance(url, str) or not url: 

1963 return False 

1964 try: 

1965 host = urlsplit(url).hostname 

1966 except Exception: 

1967 return False 

1968 if not host: 1968 ↛ 1969line 1968 didn't jump to line 1969 because the condition on line 1968 was never true

1969 return False 

1970 return _policy_host(host) in ctx.local_hostnames 

1971 

1972 

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

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

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

1976 against concurrent subagent threads. 

1977 

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

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

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

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

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

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

1984 

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

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

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

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

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

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

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

1992 paths never reach the audit log. 

1993 """ 

1994 from ..ssrf_validator import redact_url_for_log 

1995 

1996 counts_toward_quota = reason not in _NON_QUOTA_DENIAL_REASONS 

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

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

1999 qctx = _quota_ctx(ctx) 

2000 with qctx._lock: 

2001 if counts_toward_quota: 

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

2003 count = qctx._fetch_denial_count["count"] 

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

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

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

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

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

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

2010 # decided to refuse. 

2011 if not isinstance(url, str): 

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

2013 else: 

2014 audit_url = redact_url_for_log(url) 

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

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

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

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

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

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

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

2022 # reads it). The pinned 

2023 # ``test_record_denial_warning_message_carries_redacted_url`` and 

2024 # ``test_record_denial_warning_message_reaches_persisted_log`` tests 

2025 # assert this invariant. 

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

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

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

2029 ) 

2030 return Decision(False, reason) 

2031 

2032 

2033def evaluate_retriever( 

2034 retriever_name: str, 

2035 ctx: EgressContext, 

2036 *, 

2037 metadata: Optional[dict] = None, 

2038) -> Decision: 

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

2040 

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

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

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

2044 already looked up the retriever in its own registry reference) to 

2045 avoid a second registry lookup and to honor a test-patched registry; 

2046 when ``None`` the global registry is consulted. 

2047 """ 

2048 try: 

2049 # UNPROTECTED: egress protection disabled — any retriever is permitted. 

2050 if ctx.scope == EgressScope.UNPROTECTED: 

2051 return Decision(True, "egress_unprotected") 

2052 

2053 if metadata is None: 

2054 from ...web_search_engines.retriever_registry import ( 

2055 retriever_registry, 

2056 ) 

2057 

2058 try: 

2059 metadata = retriever_registry.get_metadata( 

2060 retriever_name, username=ctx.username 

2061 ) 

2062 except AttributeError: 

2063 # Older registry without metadata API; treat as unclassified. 

2064 return Decision(False, "retriever_unclassified") 

2065 

2066 if not metadata: 

2067 return Decision(False, "retriever_unknown") 

2068 

2069 is_local = metadata.get("is_local") 

2070 if is_local is None: 2070 ↛ 2071line 2070 didn't jump to line 2071 because the condition on line 2070 was never true

2071 return Decision(False, "retriever_unclassified") 

2072 

2073 if ctx.scope == EgressScope.STRICT: 

2074 # STRICT permits only the user's primary engine. A retriever 

2075 # IS allowed under STRICT when it is itself the primary (the 

2076 # common "research only against my private KB" setup); 

2077 # otherwise it's an expansion STRICT forbids. 

2078 if retriever_name == ctx.primary_engine: 

2079 return Decision(True, "allowed_primary_retriever") 

2080 return Decision(False, "strict_not_primary") 

2081 if ctx.scope == EgressScope.PUBLIC_ONLY and is_local: 

2082 return Decision(False, "scope_mismatch_public_only") 

2083 if ctx.scope == EgressScope.PRIVATE_ONLY and not is_local: 2083 ↛ 2084line 2083 didn't jump to line 2084 because the condition on line 2083 was never true

2084 return Decision(False, "scope_mismatch_private_only") 

2085 return Decision(True, "allowed") 

2086 except Exception: # pragma: no cover - defensive 

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

2088 "evaluate_retriever internal error", retriever=retriever_name 

2089 ) 

2090 return Decision(False, "internal_error") 

2091 

2092 

2093def _retriever_is_local( 

2094 retriever_name: str, username: Optional[str] 

2095) -> Optional[bool]: 

2096 """Return the ``is_local`` classification (True/False) of a registered 

2097 retriever, or ``None`` when it is unknown/unclassified. 

2098 

2099 Mirrors the registry lookup in ``evaluate_retriever`` so adaptive-scope 

2100 resolution and enforcement agree on a retriever primary's classification. 

2101 """ 

2102 try: 

2103 from ...web_search_engines.retriever_registry import ( 

2104 retriever_registry, 

2105 ) 

2106 

2107 metadata = retriever_registry.get_metadata( 

2108 retriever_name, username=username 

2109 ) 

2110 except Exception: # noqa: silent-exception - unknown → caller falls back 

2111 return None 

2112 if not metadata: 

2113 return None 

2114 is_local = metadata.get("is_local") 

2115 return is_local if isinstance(is_local, bool) else None 

2116 

2117 

2118# Bounded, thread-safe, PER-USER dedup for the ADAPTIVE fail-open WARNING 

2119# emitted below. Multiple run setup paths build contexts for the same 

2120# (user, primary) pair, so an unclassifiable primary would otherwise emit 

2121# repeated warnings. We remember, PER USER, which (sanitized) primary 

2122# engines have already warned, so a genuinely new 

2123# (user, primary) combination still logs once while repeats are silent. 

2124# 

2125# The dedup MUST be keyed on the actual user identity (a stable hash of the 

2126# username, ``None`` bucket for an unauthenticated / un-threaded run) -- NOT 

2127# merely on "was a username present". Keying on presence alone means a 

2128# genuine fail-open for user B on a primary name already logged for user A is 

2129# silently swallowed: the audit signal for B never fires. Keying on identity 

2130# keeps each user's fail-opens independently detectable. 

2131# 

2132# ``primary_engine`` is user-controlled (``search.tool``), so the dedup state 

2133# is bounded in BOTH dimensions -- mirroring the capped ``warned: set`` dedup 

2134# in ``web/services/socketio_asgi.py::_install_origin_rejection_logging``: 

2135# * each user's bucket caps the distinct primaries it remembers 

2136# (``_ADAPTIVE_FAILOPEN_WARN_CAP_PER_USER``); once full it stops 

2137# warning/recording, exactly as the old single global set did -- but now 

2138# that ceiling is reached PER USER, so it only ever silences the ONE user 

2139# who flooded it, and 

2140# * the set of tracked users is a bounded LRU 

2141# (``_ADAPTIVE_FAILOPEN_MAX_USERS``); the least-recently-seen user's whole 

2142# bucket is evicted beyond the cap (which can only ever re-enable a 

2143# warning, never suppress one). 

2144# A single authenticated user flooding distinct junk primaries therefore only 

2145# ever exhausts THEIR OWN bucket -- it can never suppress the fail-open audit 

2146# warnings of any other user (the bug a single global set had: 512 junk 

2147# primaries from one user permanently silenced everyone). Total memory is 

2148# capped at MAX_USERS * WARN_CAP_PER_USER short strings. 

2149_ADAPTIVE_FAILOPEN_WARNED: "OrderedDict[Optional[str], OrderedDict[str, None]]" = OrderedDict() 

2150_ADAPTIVE_FAILOPEN_LOCK = threading.Lock() 

2151# Max distinct (sanitized) primaries remembered per user; once a user's bucket 

2152# is full we stop warning/recording for that user (memory-safe, and preserves 

2153# the original anti-log-amplification behavior) -- but PER USER, so it never 

2154# touches another user's audit budget. 

2155_ADAPTIVE_FAILOPEN_WARN_CAP_PER_USER = 128 

2156# Max distinct users tracked at once; the least-recently-seen user's whole 

2157# bucket is LRU-evicted beyond this, keeping total memory bounded. 

2158_ADAPTIVE_FAILOPEN_MAX_USERS = 256 

2159 

2160 

2161def _adaptive_failopen_user_key(username: Optional[str]) -> Optional[str]: 

2162 """Stable, bounded-length bucket key for the per-user fail-open dedup. 

2163 

2164 ``None`` (unauthenticated / un-threaded run) is its own bucket. A present 

2165 username is hashed rather than stored raw: it bounds the key length 

2166 regardless of the username, and avoids retaining plaintext identities in 

2167 this long-lived process-global audit structure (the warning itself only 

2168 ever emits ``username_present``, never the name). 

2169 """ 

2170 if username is None: 

2171 return None 

2172 return hashlib.sha256(username.encode("utf-8")).hexdigest()[:16] 

2173 

2174 

2175def _should_warn_adaptive_failopen( 

2176 sanitized_primary: str, username: Optional[str] 

2177) -> bool: 

2178 """True the first time this ``(user, sanitized_primary)`` pair is seen. 

2179 

2180 Dedup is PER USER: the bucket key is a stable hash of ``username`` 

2181 (``None`` for an unauthenticated run), so a genuine fail-open for one user 

2182 is never swallowed by an already-logged fail-open for a DIFFERENT user on 

2183 the same primary name. Returns False for a repeat of an already-recorded 

2184 pair, and for any NEW primary once *this* user's bucket is full (bounded, 

2185 and only that user is affected). The set of tracked users is a bounded LRU 

2186 -- see the ``_ADAPTIVE_FAILOPEN_WARNED`` docstring above for the 

2187 memory-safety and cross-user-isolation rationale. 

2188 """ 

2189 user_key = _adaptive_failopen_user_key(username) 

2190 with _ADAPTIVE_FAILOPEN_LOCK: 

2191 bucket = _ADAPTIVE_FAILOPEN_WARNED.get(user_key) 

2192 if bucket is None: 

2193 # First fail-open for this user. Enforce the users-tracked cap by 

2194 # LRU-evicting the least-recently-seen user before inserting. 

2195 if len(_ADAPTIVE_FAILOPEN_WARNED) >= _ADAPTIVE_FAILOPEN_MAX_USERS: 

2196 _ADAPTIVE_FAILOPEN_WARNED.popitem(last=False) 

2197 bucket = OrderedDict() 

2198 _ADAPTIVE_FAILOPEN_WARNED[user_key] = bucket 

2199 else: 

2200 # Seen this user before -> mark most-recently-used so an active 

2201 # user is never the one evicted by the users cap. 

2202 _ADAPTIVE_FAILOPEN_WARNED.move_to_end(user_key) 

2203 

2204 if sanitized_primary in bucket: 

2205 return False 

2206 # This user's per-user budget is full: stop warning AND recording, so 

2207 # one user's junk-primary flood can neither grow memory nor amplify 

2208 # logs. Bounded and scoped to this ONE user's bucket -- every other 

2209 # user keeps their full audit budget. 

2210 if len(bucket) >= _ADAPTIVE_FAILOPEN_WARN_CAP_PER_USER: 

2211 return False 

2212 bucket[sanitized_primary] = None 

2213 return True 

2214 

2215 

2216def _resolve_adaptive_scope( 

2217 primary_engine: str, 

2218 settings_snapshot: dict, 

2219 *, 

2220 username: Optional[str], 

2221 local_hostnames: tuple, 

2222 allow_dns: bool = True, 

2223) -> EgressScope: 

2224 """Map ADAPTIVE to a concrete scope by classifying the primary engine. 

2225 

2226 Uses the engine's **static class flags** (plus ``classify_engine``'s 

2227 fail-up URL override) so that e.g. a locally-hosted SearXNG is still 

2228 treated as a public engine, and a remote-hosted Elasticsearch resolves 

2229 public rather than pulling the run into PRIVATE_ONLY. 

2230 

2231 private primary => PRIVATE_ONLY, public primary => PUBLIC_ONLY, 

2232 unknown / classification-error => BOTH (adaptive's documented 

2233 permissive fallback, equivalent to pre-policy behavior, so a 

2234 classification hiccup never hard-fails the run). 

2235 

2236 A registered LangChain retriever (private KB) is not in ENGINE_REGISTRY, 

2237 so ``classify_engine`` returns no classification for it; we then consult the 

2238 retriever registry so a local retriever primary resolves to PRIVATE_ONLY 

2239 (forcing local inference) rather than leaking the corpus to cloud models. 

2240 """ 

2241 if not primary_engine: 

2242 return EgressScope.BOTH 

2243 try: 

2244 probe_ctx = EgressContext( 

2245 scope=EgressScope.BOTH, 

2246 primary_engine=primary_engine, 

2247 require_local_llm=False, 

2248 require_local_embeddings=False, 

2249 local_hostnames=local_hostnames, 

2250 username=username, 

2251 ) 

2252 classification = classify_engine( 

2253 primary_engine, 

2254 probe_ctx, 

2255 settings_snapshot=settings_snapshot, 

2256 allow_dns=allow_dns, 

2257 ) 

2258 except Exception: # noqa: silent-exception - adaptive falls back to BOTH 

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

2260 "adaptive scope classification failed; falling back to BOTH", 

2261 primary=primary_engine, 

2262 ) 

2263 return EgressScope.BOTH 

2264 

2265 if classification.is_local is True and classification.is_public is not True: 

2266 return EgressScope.PRIVATE_ONLY 

2267 if classification.is_public is True and classification.is_local is not True: 

2268 return EgressScope.PUBLIC_ONLY 

2269 

2270 # SECURITY (fail-open regression): a PRIVATE non-contained collection — 

2271 # its vector store lives off this machine — classifies as 

2272 # (is_public=False, is_local=False). ``classify_engine``'s fail-up logic 

2273 # (see its docstring) strips ``is_local`` when containment is lost while 

2274 # PRESERVING the real ``is_public`` flag, so this is the mirror image of 

2275 # a PUBLIC non-contained collection: that one correctly matches the 

2276 # ``is_public is True`` branch above and resolves PUBLIC_ONLY, but a 

2277 # PRIVATE one matches NEITHER exclusive branch and, without this rule, 

2278 # fell through all the way to the permissive ``BOTH`` fallback below — 

2279 # silently dropping the forced-local-LLM/embeddings guarantee 

2280 # (``context_from_snapshot``'s PRIVATE_ONLY coupling) for exactly the 

2281 # collection that most needs it, and resolving MORE permissively than 

2282 # the public case despite being the more sensitive one. Resolve to the 

2283 # RESTRICTIVE PRIVATE_ONLY instead — this preserves the pre-PR 

2284 # forced-local posture. The collection engine itself still gets denied 

2285 # by ``evaluate_engine`` as "unclassified" (neither flag is True) 

2286 # regardless of the scope resolved here; PRIVATE_ONLY's role is to keep 

2287 # every OTHER engine/inference path in the run local too, rather than 

2288 # letting the run silently widen to cloud-capable BOTH. This branch is 

2289 # reached via the private-non-contained-collection fail-up above, or by a 

2290 # static engine whose class overrides NEITHER flag: ``BaseSearchEngine`` 

2291 # declares ``is_public = False`` and ``is_local = False`` as CLASS 

2292 # DEFAULTS (``search_engine_base.py``), so such a subclass inherits 

2293 # ``(False, False)`` from the base class — it need not explicitly write 

2294 # either assignment itself — and lands HERE, not in the ``(None, None)`` 

2295 # branch below. ``_engine_flags`` only returns ``None`` for a flag that is 

2296 # genuinely absent from the class's MRO, which no attribute on 

2297 # ``BaseSearchEngine`` ever is; all 29 current registry engines override 

2298 # exactly one flag to ``True`` and so don't reach this branch today, but 

2299 # the next engine author who forgets to override either flag will, not 

2300 # the retriever-lookup fallback. Dedup/log via the same per-(user, primary) mechanism 

2301 # as the fail-open warning below — this fires on the identical hot path 

2302 # (once per research-run start AND once per fetch-gate check). 

2303 if classification.is_public is False and classification.is_local is False: 

2304 safe_primary = sanitize_for_log(primary_engine) 

2305 if _should_warn_adaptive_failopen(f"nonlocal:{safe_primary}", username): 

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

2307 f"ADAPTIVE egress: primary engine '{safe_primary}' classified " 

2308 f"as neither public nor local-contained (a PRIVATE collection " 

2309 f"whose vector store is not on this machine, or a " 

2310 f"mis-declared static engine); resolving to the RESTRICTIVE " 

2311 f"PRIVATE_ONLY scope (forcing local LLM/embeddings) rather " 

2312 f"than the permissive BOTH." 

2313 ) 

2314 return EgressScope.PRIVATE_ONLY 

2315 

2316 # Unknown to classify_engine — it may be a registered retriever (private 

2317 # KB). Classify via the retriever registry before falling back to BOTH. 

2318 if classification.is_public is None and classification.is_local is None: 

2319 retriever_local = _retriever_is_local(primary_engine, username) 

2320 if retriever_local is True: 

2321 return EgressScope.PRIVATE_ONLY 

2322 if retriever_local is False: 

2323 return EgressScope.PUBLIC_ONLY 

2324 

2325 # SECURITY (detectable fail-open): the primary could not be classified 

2326 # in ANY namespace visible here — it is not a static engine, not a 

2327 # collection, and not a retriever resolvable for ``username``. ADAPTIVE 

2328 # therefore resolves to the PERMISSIVE ``BOTH`` (cloud-capable) scope. 

2329 # 

2330 # The benign cause is a genuinely-unregistered / typo'd / removed 

2331 # meta-engine primary. The DANGEROUS cause is a per-user PRIVATE 

2332 # retriever that is invisible here because ``username=`` was not 

2333 # threaded into this resolution (or the snapshot lacked ``_username``): 

2334 # the retriever would classify as local (=> PRIVATE_ONLY, forcing local 

2335 # inference) if its owner's namespace were consulted, but under the 

2336 # wrong/absent username it disappears and the run silently fails open 

2337 # to cloud-capable BOTH. 

2338 # 

2339 # We do NOT fail closed here: reaching this branch means classify_engine 

2340 # returned (None, None), i.e. a *legitimately-public* primary can never 

2341 # land here (a public engine classifies is_public=True => PUBLIC_ONLY 

2342 # above). But we still cannot cleanly separate "benign unregistered 

2343 # primary" from "invisible per-user retriever" without peeking across 

2344 # other users' registry namespaces (breaking the isolation this PR 

2345 # establishes, and misfiring PRIVATE_ONLY onto a legitimate 

2346 # unregistered public primary that merely shares a name). Since the 

2347 # distinction isn't clean, per the security-hardening guidance we keep 

2348 # the non-breaking permissive behavior and make it DETECTABLE instead: 

2349 # a missed ``username=`` threading now shows up as this WARNING rather 

2350 # than silently permissive egress. 

2351 # primary_engine is user-controlled (search.tool): scrub it before 

2352 # interpolating into the log line so embedded newlines/control chars 

2353 # can't forge additional log entries (log injection). Dedup on the 

2354 # SANITIZED value too, per _should_warn_adaptive_failopen above, so 

2355 # this fires at most once per unique (user, primary) pair in the 

2356 # process rather than once per call. The dedup keys on the ACTUAL 

2357 # username (hashed), not merely "was one present", so a genuine 

2358 # fail-open for one user is never swallowed by another user's. 

2359 safe_primary = sanitize_for_log(primary_engine) 

2360 if _should_warn_adaptive_failopen(safe_primary, username): 

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

2362 f"ADAPTIVE egress fail-open: primary engine " 

2363 f"'{safe_primary}' could not be classified in any visible " 

2364 f"namespace (static engine, collection, or retriever visible to " 

2365 f"this user); resolving PERMISSIVELY to BOTH (cloud-capable). If " 

2366 f"'{safe_primary}' is a per-user PRIVATE retriever this is a " 

2367 f"fail-open — verify username= is threaded into egress " 

2368 f"resolution (username_present={username is not None})." 

2369 ) 

2370 return EgressScope.BOTH 

2371 

2372 return EgressScope.BOTH 

2373 

2374 

2375def context_from_snapshot( 

2376 settings_snapshot: dict, 

2377 primary_engine: str, 

2378 *, 

2379 username: Optional[str] = None, 

2380 allow_dns: bool = True, 

2381) -> EgressContext: 

2382 """Construct the frozen ``EgressContext`` for a research run. 

2383 

2384 Reads policy settings out of the snapshot exactly once at run-start. 

2385 A missing ``policy.egress_scope`` yields ``DEFAULT_EGRESS_SCOPE`` 

2386 (``adaptive`` — the protective default that follows the primary engine), 

2387 NOT a permissive fallback. A residual ``both`` value is coerced to 

2388 ``adaptive`` below. 

2389 

2390 ``allow_dns=False`` makes ADAPTIVE resolution skip the synchronous 

2391 getaddrinfo when classifying a URL-configurable primary engine — used by 

2392 the advisory warning-banner render path so a settings-page load never 

2393 blocks on DNS. Enforcement callers leave it True so classification is 

2394 accurate. (Has no effect for non-ADAPTIVE scopes, which never resolve 

2395 a URL.) 

2396 """ 

2397 if settings_snapshot is None: 

2398 raise ValueError("settings_snapshot is required") 

2399 if not isinstance(settings_snapshot, dict): 

2400 # Fail closed with the same ValueError contract callers already 

2401 # handle (llm_config / research_service convert it to a hard policy 

2402 # stop). A non-dict snapshot would otherwise crash deeper in 

2403 # _get_setting_value with a bare AttributeError, which a broad 

2404 # caller-side except could swallow into a permissive default. 

2405 raise ValueError( 

2406 f"settings_snapshot must be a dict, got " 

2407 f"{type(settings_snapshot).__name__}" 

2408 ) 

2409 

2410 scope_raw = _get_setting_value( 

2411 settings_snapshot, "policy.egress_scope", DEFAULT_EGRESS_SCOPE 

2412 ) 

2413 from ...settings.manager import check_env_setting 

2414 

2415 env_scope = check_env_setting("policy.egress_scope") 

2416 if env_scope is not None: 

2417 scope_raw = env_scope 

2418 # `both` is retired (ADR-0007): the blanket-permissive middle scope is 

2419 # coerced to the protective `adaptive` default so a residual value — an 

2420 # un-migrated DB, a queued snapshot, or an env-var override that bypasses 

2421 # settings validation — can never silently keep the old no-protection 

2422 # behaviour. Migration 0019 rewrites stored values; this is the read-time 

2423 # backstop. (EgressScope.BOTH still exists as the INTERNAL resolution result 

2424 # for an unclassifiable ADAPTIVE primary — it is never a user-facing scope.) 

2425 if str(scope_raw).strip().lower() == EgressScope.BOTH.value: 

2426 scope_raw = EgressScope.ADAPTIVE.value 

2427 try: 

2428 scope = parse_user_egress_scope( 

2429 scope_raw, disabled_unprotected="adaptive" 

2430 ) 

2431 except PolicyDeniedError: 

2432 # An unrecognised scope means the saved setting was corrupted 

2433 # or tampered with — silently falling back to BOTH (the most 

2434 # permissive scope) would mask policy violations rather than 

2435 # surface them. Refuse the run instead so the operator notices. 

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

2437 "refusing to construct EgressContext from unknown scope", 

2438 value=scope_raw, 

2439 ) 

2440 raise 

2441 

2442 if ( 

2443 str(scope_raw).strip().lower() == EgressScope.UNPROTECTED.value 

2444 and scope == EgressScope.ADAPTIVE 

2445 ): 

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

2447 "unprotected egress is disabled; coercing scope to adaptive" 

2448 ) 

2449 

2450 env_require_local_llm = check_env_setting("llm.require_local_endpoint") 

2451 env_require_local_embeddings = check_env_setting("embeddings.require_local") 

2452 require_local_llm = _coerce_bool( 

2453 env_require_local_llm 

2454 if env_require_local_llm is not None 

2455 else _get_setting_value( 

2456 settings_snapshot, "llm.require_local_endpoint", False 

2457 ) 

2458 ) 

2459 require_local_embeddings = _coerce_bool( 

2460 env_require_local_embeddings 

2461 if env_require_local_embeddings is not None 

2462 else _get_setting_value( 

2463 settings_snapshot, "embeddings.require_local", False 

2464 ) 

2465 ) 

2466 

2467 raw_hostnames = _get_setting_value( 

2468 settings_snapshot, "llm.allowed_local_hostnames", () 

2469 ) 

2470 if isinstance(raw_hostnames, (list, tuple)): 2470 ↛ 2473line 2470 didn't jump to line 2473 because the condition on line 2470 was always true

2471 local_hostnames = tuple(h for h in raw_hostnames if isinstance(h, str)) 

2472 else: 

2473 local_hostnames = () 

2474 

2475 # ADAPTIVE resolves to a concrete scope by classifying the primary 

2476 # engine: a concrete private primary => PRIVATE_ONLY, a concrete public 

2477 # primary => PUBLIC_ONLY, an unclassifiable primary => BOTH. 

2478 # The resolved scope is what the EgressContext stores and what 

2479 # every downstream PEP enforces. Classification reuses classify_engine 

2480 # via a throwaway BOTH-scoped context (its DNS cache is discarded). 

2481 if scope == EgressScope.ADAPTIVE: 

2482 scope = _resolve_adaptive_scope( 

2483 primary_engine, 

2484 settings_snapshot, 

2485 username=username, 

2486 local_hostnames=local_hostnames, 

2487 allow_dns=allow_dns, 

2488 ) 

2489 

2490 # PRIVATE_ONLY means "my data stays on this box." That guarantee only 

2491 # holds if BOTH inference paths are local — a cloud LLM receives the 

2492 # query + retrieved local chunks, and a cloud embedder receives the 

2493 # whole corpus at ingest. So under PRIVATE_ONLY the require_local_* 

2494 # flags are IMPLIED: scope overrides them, so a user who left them at 

2495 # their default (False) can't silently exfiltrate via inference. This 

2496 # also fires when ADAPTIVE resolved to PRIVATE_ONLY above. 

2497 # 

2498 # STRICT is deliberately NOT coupled here: it restricts the search 

2499 # engine set to the primary only and is orthogonal to where inference 

2500 # runs (a user may legitimately want single-engine search + a cloud 

2501 # LLM). Callers that gate on ctx.require_local_* therefore get 

2502 # scope-correct behaviour without a separate flag read. 

2503 if scope == EgressScope.PRIVATE_ONLY: 

2504 require_local_llm = True 

2505 require_local_embeddings = True 

2506 elif scope == EgressScope.UNPROTECTED: 

2507 # The escape hatch lifts user-level locality requirements, but cannot 

2508 # weaken immutable operator environment locks. 

2509 if env_require_local_llm is None: 

2510 require_local_llm = False 

2511 if env_require_local_embeddings is None: 

2512 require_local_embeddings = False 

2513 

2514 return EgressContext( 

2515 scope=scope, 

2516 primary_engine=primary_engine, 

2517 require_local_llm=require_local_llm, 

2518 require_local_embeddings=require_local_embeddings, 

2519 local_hostnames=local_hostnames, 

2520 username=username, 

2521 ) 

2522 

2523 

2524def _get_setting_value(snapshot: dict, key: str, default): 

2525 """Read a setting from the snapshot. 

2526 

2527 Accepts both flat ({key: value}) and nested ({key: {"value": value}}) 

2528 schemas — both shapes appear in LDR's settings infrastructure. 

2529 """ 

2530 raw = snapshot.get(key, default) 

2531 return unwrap_setting(raw) 

2532 

2533 

2534def coerce_str_list(raw) -> tuple[bool, list[str]]: 

2535 """Decode a JSON-list-ish setting value into a list of strings. 

2536 

2537 Returns ``(ok, values)``. Accepts a real list/tuple or a JSON-string list 

2538 and filters to string entries. ``ok`` is False only when a non-empty JSON 

2539 string failed to parse — callers that must *reject* malformed input branch 

2540 on it; fail-safe callers ignore it and use the (empty) list. Single home for 

2541 the decode shared by the trust resolver, the trust banner, and the 

2542 settings-save validators. 

2543 """ 

2544 if isinstance(raw, str): 

2545 if not raw.strip(): 2545 ↛ 2546line 2545 didn't jump to line 2546 because the condition on line 2545 was never true

2546 return True, [] 

2547 try: 

2548 raw = json.loads(raw) 

2549 except Exception: 

2550 return False, [] 

2551 if isinstance(raw, (list, tuple)): 2551 ↛ 2553line 2551 didn't jump to line 2553 because the condition on line 2551 was always true

2552 return True, [x for x in raw if isinstance(x, str)] 

2553 return True, [] 

2554 

2555 

2556def resolve_run_primary_engine( 

2557 settings_snapshot: Optional[dict], default: Optional[str] = None 

2558) -> str: 

2559 """Resolve a research run's primary search engine id from a snapshot. 

2560 

2561 Single source of truth for "which engine drives this run's egress 

2562 scope". Under the default ADAPTIVE scope the concrete scope is derived by 

2563 classifying THIS primary, so run-scoped callers that build an 

2564 ``EgressContext`` should derive the primary here. Otherwise two layers can 

2565 resolve ADAPTIVE to different scopes and one silently under-enforces — the 

2566 LangGraph tool-list filter once derived the primary from the engine CLASS 

2567 name (a collection -> ``"libraryrag"`` -> unclassified -> BOTH) while the 

2568 factory PEP derived it from ``search.tool`` (the real collection key -> 

2569 PRIVATE_ONLY), so a public engine reached the agent and was then 

2570 hard-denied mid-run (``scope_mismatch_private_only``). 

2571 

2572 Reads ``search.tool`` (flat or ``{"value": ...}`` shaped). A blank-after- 

2573 strip (``" "``) or non-string (``5``, list/dict) value is treated as 

2574 MISSING — it must not slip through the truthiness check and classify to the 

2575 permissive BOTH scope. 

2576 

2577 A run with NO configured primary is a wiring error, not a normal state. 

2578 Silently substituting a default would set the egress scope from an engine 

2579 the user never chose — and since the system default is public (``searxng``), 

2580 that fails OPEN. So a missing/empty ``search.tool`` raises ``ValueError`` 

2581 unless the caller passes an explicit ``default``: 

2582 

2583 * run-level callers (research_service, the strategy tool-list filter, 

2584 ``filter_candidates_by_egress``) pass none and fail closed — the worker 

2585 refuses the run; advisory filters degrade to unfiltered with the factory 

2586 PEP still enforcing. 

2587 * the search-engine factory and per-engine checks pass 

2588 ``default=engine_name`` outside STRICT because they evaluate that one 

2589 specific engine. Under STRICT they require the saved primary so an 

2590 engine cannot authorize itself by becoming the fallback. 

2591 

2592 Raises: 

2593 ValueError: The snapshot is neither a dictionary nor None, or 

2594 ``search.tool`` is missing/blank/non-string and no truthy 

2595 ``default`` is given. 

2596 """ 

2597 if settings_snapshot is not None and not isinstance( 

2598 settings_snapshot, dict 

2599 ): 

2600 raise ValueError( 

2601 "settings_snapshot must be a dict, got " 

2602 f"{type(settings_snapshot).__name__}" 

2603 ) 

2604 primary = ( 

2605 _get_setting_value(settings_snapshot, "search.tool", None) 

2606 if settings_snapshot 

2607 else None 

2608 ) 

2609 # Blank-after-strip or non-string is not a usable engine id → missing. 

2610 primary = primary.strip() if isinstance(primary, str) else None 

2611 if primary: 

2612 return primary 

2613 if default: 

2614 return default 

2615 raise ValueError( 

2616 "no primary search engine configured: settings 'search.tool' is " 

2617 "missing, blank, or not a string, so the run's egress scope cannot " 

2618 "be resolved" 

2619 ) 

2620 

2621 

2622def build_run_egress_context( 

2623 settings_snapshot: Optional[dict], 

2624 search_engine: Optional[str], 

2625 *, 

2626 username: Optional[str] = None, 

2627) -> EgressContext: 

2628 """Resolve the research worker's per-run ``EgressContext``. 

2629 

2630 Composes the policy setup ``run_research_process`` runs at startup: 

2631 current operator env policy is reapplied on the snapshot, the primary 

2632 engine is derived, and the ``EgressContext`` is constructed. This is the 

2633 single production seam invoked by the worker; a stale queued snapshot 

2634 carrying an operator-disabled ``unprotected`` scope is coerced to a 

2635 protected scope here (via the read-time backstop in 

2636 ``context_from_snapshot``), not at queue extraction time. 

2637 

2638 Under ``STRICT`` the primary is the *saved* snapshot primary only — a 

2639 blank/missing saved primary raises ``ValueError`` (fail closed) rather 

2640 than letting the request choose its own primary. Under every other 

2641 scope the requested engine wins when present, matching 

2642 ``_precheck_engine_policy``. 

2643 """ 

2644 from ...settings.manager import ( 

2645 apply_environment_overrides_to_snapshot, 

2646 ) 

2647 

2648 effective = apply_environment_overrides_to_snapshot(settings_snapshot or {}) 

2649 scope_raw = _get_setting_value( 

2650 effective, "policy.egress_scope", DEFAULT_EGRESS_SCOPE 

2651 ) 

2652 if str(scope_raw).strip().lower() == EgressScope.STRICT.value: 

2653 primary = resolve_run_primary_engine(effective) 

2654 else: 

2655 saved_primary = resolve_run_primary_engine( 

2656 effective, default=search_engine or None 

2657 ) 

2658 primary = search_engine or saved_primary 

2659 return context_from_snapshot(effective, primary, username=username) 

2660 

2661 

2662def _coerce_bool(value) -> bool: 

2663 """Coerce a setting value to bool, defensively. 

2664 

2665 Strings ``"true"`` / ``"True"`` are True; anything else string-shaped 

2666 is False. Real booleans pass through. The strict coercion prevents 

2667 type confusion (e.g., the string ``"false"`` being truthy). 

2668 """ 

2669 if isinstance(value, bool): 

2670 return value 

2671 if isinstance(value, str): 2671 ↛ 2673line 2671 didn't jump to line 2673 because the condition on line 2671 was always true

2672 return value.strip().lower() in ("true", "1", "yes", "on") 

2673 return bool(value) 

2674 

2675 

2676def coerce_policy_bool(value) -> bool: 

2677 """Public alias for the coercion the policy applies to its own flags. 

2678 

2679 UI code that has to report *whether the policy is in force* (for example 

2680 the reason a provider is greyed out) must decide truthiness the same way 

2681 ``context_from_snapshot`` does. HTML-checkbox parsers such as 

2682 ``settings.manager.parse_boolean`` treat every non-falsy string as True, 

2683 so ``LDR_LLM_REQUIRE_LOCAL_ENDPOINT=enabled`` would be reported as a 

2684 server lock while the policy itself computes ``require_local_llm=False``. 

2685 """ 

2686 return _coerce_bool(value)