### πŸ’₯ Breaking Changes - Removed the `mcp` / `agentic` (ReAct) search strategy. Existing selections are automatically migrated to `langgraph-agent` (a near functional superset); connecting to external MCP servers as research tool sources is no longer supported. (LDR still ships its own MCP **server** for exposing research to assistants like Claude β€” that is unaffected.) ([#4548](https://github.com/LearningCircuit/local-deep-research/pull/4548)) - **Breaking (programmatic API):** `get_llm(provider=…)` and `get_embeddings(provider=…)` now **fail closed** when called with no settings snapshot (`settings_snapshot=None`) for any provider that is not a known local-default. Snapshot-less callers may instantiate only the localhost-default providers β€” LLM: `ollama`, `lmstudio`, `llamacpp`; embeddings: `sentence_transformers`, `ollama` β€” plus LLMs you registered in-process via the programmatic API (`quick_summary(llms={…})`). Any other provider (incl. `openai`, `anthropic`, `google`, `openrouter`, and any future cloud provider) raises `PolicyDeniedError` instead of silently constructing a cloud client. This closes a snapshot-less egress hole; programmatic callers that relied on building a cloud provider without a snapshot must now pass a `settings_snapshot` (or register the LLM in-process). The allowlists are intentionally tight so unknown/new providers fail closed by default. - **The `auto` and `parallel` meta search engines have been removed** (including the `meta` and `parallel_scientific` aliases). The default langgraph-agent strategy replaces them β€” it selects search engines dynamically per query, so a separate LLM-based engine picker is redundant. Stored values (`search.tool` setting, news subscriptions, queued researches, saved benchmark configs) are migrated automatically on upgrade (migration 0013; `search.tool` values pointing at a removed engine become `searxng`). **What to do:** API callers passing `search_tool="auto"` or `"parallel"` must pick a concrete engine (e.g. `searxng`), and `LDR_SEARCH_TOOL=auto` environment overrides must be updated likewise. - **Upgrade behaviour change:** the default egress scope is now `adaptive` (was effectively `both`). Adaptive follows your primary search engine, so on upgrade a config with a *concrete* primary may narrow: a public primary (e.g. SearXNG/arXiv) now excludes local collections (public-only behaviour), and a *private collection* primary forces local LLM + embeddings. To keep the previous "any engine, cloud inference" behaviour, set Egress Scope to **Both**. See docs/egress-modes.md. ### πŸ”’ Security - News recommendation logging now sanitizes externally-derived topic strings (and search error text) before interpolating them into log records, closing a log-injection vector where crafted news content could forge log lines or inject terminal escape sequences. ([#3767](https://github.com/LearningCircuit/local-deep-research/pull/3767)) - **`/settings/api` no longer leaks env-overridden API keys to authenticated users.** The bulk settings JSON endpoint now redacts password-typed values (`llm.openai.api_key`, search-engine API keys, etc.) so they come back as `[REDACTED]` instead of plaintext. Metadata is preserved so the UI still renders correctly. Note: the settings form template still pre-fills password inputs server-side β€” that's a separate UX issue and not addressed here. ([#3947](https://github.com/LearningCircuit/local-deep-research/pull/3947)) - **Settings form no longer pre-fills password inputs with the stored value**, eliminating an authenticated View-Source disclosure of API keys / OAuth tokens. Password fields render empty with a placeholder indicating configuration state and `autocomplete="new-password"` to prevent browser caching. ([#3954](https://github.com/LearningCircuit/local-deep-research/pull/3954)) - Extended `redact_secrets()` coverage to credential-bearing exception handlers in the encrypted-database, web queue, and scheduler subsystems β€” sites where the user's SQLCipher master password is in lexical scope. Unlike API keys (rotatable), the SQLCipher master password is unrecoverable (TRUST.md Β§5), so a leak via a rendered traceback or upstream exception message is a permanent compromise. Covers `database/encrypted_db.py` (6 sites in `create_user_database` / `open_user_database` / `change_password`), `web/queue/processor_v2.py:_start_research_directly`, and 11 sites across `scheduler/background.py` (subscription scheduling, document processing, RAG indexing, overdue-subscription handling, subscription research trigger). See #4182. ([#4182](https://github.com/LearningCircuit/local-deep-research/pull/4182)) - Egress policy hardening (PR #4300, Round 6 review): 24 fixes across DNS classification (process-global socket timeout removed, threading.RLock around cache mutations), PDP coverage (nested-dict settings unwrap, NAT64 metadata reclassification, fetch_content tool gate), engine/RAG/data coverage (MetaSearchEngine Wikipedia fallback routed through factory, all 5 direct LibraryRAGService sites pre-flight-checked, SearchCache hash includes scope to close cache-bypass), and UI/cross-cutting work (per-research policy overrides on the research form, settings dashboard renders the policy keys, benchmark snapshot threading, policy_audit WebSocket filter, audit log on policy-key changes, dismiss-flag rename to follow the dismiss_* convention). 23 regression tests added. A second adversarial review round added 13 more fixes: closed two egress bypasses (Elasticsearch `cloud_id` is now refused under private scope; OpenAI/discovered-provider model-list probes are scope-gated in both the settings and RAG model endpoints); closed a metadata-SSRF gap (literal cloud-metadata IPs are now classified PUBLIC on the literal-IP branch of `_classify_host`, matching the DNS branch); ADAPTIVE now resolves a registered local-retriever primary to PRIVATE_ONLY (was BOTH, which leaked the private corpus to cloud inference); the PEP-578 audit-hook backstop is now armed on non-web entry points (CLI / news scheduler / programmatic API) and re-armed on ThreadPoolExecutor pool workers; fixed the SearXNG `url_setting` key so a local SearXNG is no longer over-blocked under PRIVATE_ONLY; `allowed_local_hostnames` no longer rejects unresolvable intranet hosts on save; the denied-fetch quota no longer counts benign parse failures; and `DownloadService` fails closed when the settings backend errors. 10 regression tests added. Follow-up regression fix: user-registered in-process LLMs (the programmatic API's `llms={...}` and plugins) are no longer denied with `provider_url_unset` when a run resolves to require-local inference (e.g. ADAPTIVE with a registered-retriever primary). The PDP now distinguishes user registrations from auto-discovered built-in providers β€” a custom name shadowing a built-in cloud provider stays blocked, and snapshot-less calls keep failing closed for everything else. 3 regression tests added. Third adversarial review round (5Γ—20 agents) confirmed and fixed 6 more issues (4 of 11 verified findings were declined as not-exploitable or design-intent, 1 was a false positive): the `evaluate_url` cloud-metadata block now normalizes alternate IPv4 encodings (octal `0251.0376.0251.0376`, hex, integer) so an IMDS literal can't read as an allowed public host under PUBLIC_ONLY/BOTH and always denies with the explicit `blocked_metadata_ip` reason; `dangerous_scheme` (javascript:/data:/file: hrefs) no longer counts toward the per-run denied-fetch quota (matching `unsupported_scheme`, so a document full of data: URIs can't starve later legitimate fetches); `evaluate_llm_endpoint` and `evaluate_embeddings` now percent-decode the host before classification (consistency with `evaluate_url`, so a legitimate percent-encoded local endpoint isn't wrongly denied under require-local); `context_from_snapshot` fails closed with `ValueError` on a non-dict snapshot instead of a swallowable `AttributeError`; and the run-scoped DNS classification cache is now first-writer-wins so concurrent disagreeing lookups (round-robin DNS) can't flip a hostname's classification mid-run. 11 regression tests added. Round 2 of the 5Γ—20 review (audit-hook + thread-context propagation) confirmed and fixed 4 issues (of 21 findings: 6 false positives, 1 already-fixed, several lower-severity backstop-completeness gaps deferred with the primary PEPs as the live gate): the PEP-578 audit hook now decodes bytes-encoded socket addresses before classifying (CPython fires `socket.connect` with a bytes host, which previously passed straight through the PRIVATE_ONLY/STRICT backstop); `set_active_context` rejects an unresolved ADAPTIVE-scope context (it would have silently no-op'd the hook); `active_egress_context` saves and restores the previous context so a nested activation no longer wipes the parent's; and the default `langgraph-agent` strategy now re-arms the audit-hook backstop inside each subagent ThreadPoolExecutor worker (threading.local isn't inherited by pool workers). 4 regression tests added. Round 3 of the 5Γ—20 review (call-site PEPs / data-egress paths) confirmed the empty-snapshot fail-open as the one genuinely exploitable issue and fixed it (most of the 18 findings were lower-severity than first rated β€” UI-display consistency, config-time validations already backstopped by execution-time PEPs, documented accepted-risk redirects, or backward-compatible empty-snapshot defaults): the `quick_summary` REST endpoint now fails CLOSED (HTTP 503) when the user's settings snapshot can't be loaded, instead of continuing with an empty `{}` snapshot that silently downgraded a configured PRIVATE_ONLY/require-local user to the permissive BOTH scope. News-subscription policy validation now also surfaces an incoherent egress config (STRICT scope + meta-picker primary, which raises ValueError) as a fail-fast misconfiguration error at create/update time rather than silently skipping the check. 1 regression test added. Round 4 of the 5Γ—20 review (adversarial bypass hunting) found no new exploitable bypass β€” every candidate was a known/documented accepted-risk (DNS-rebinding and redirect TOCTOU, per-context fetch quota, connect-only audit-hook scope), a fail-safe over-restriction, or an operator-trust-boundary case (a programmatic caller controls its own snapshot). One cheap defense-in-depth completion was applied: `evaluate_url` now also blocks cloud-metadata endpoints reachable by hostname (GCP `metadata.google.internal` / `metadata.goog`, any case) and strips an insignificant trailing dot before the metadata checks, so `metadata.google.internal.` and `169.254.169.254.` can no longer read as an allowed public host (closing the hostname/trailing-dot gaps the round-1 octal/hex/integer fix didn't cover). 2 regression tests added. Round 5 of the 5Γ—20 review (quality / docs / UI / completeness) fixed 5 issues: policy-audit denial logs now redact URLs (scheme://host:port only) via the existing `redact_url_for_log` at all four sites (ContentFetcher, DownloadService, and both full_search paths), so a denied URL carrying userinfo credentials or an API-key query param is no longer written verbatim to the audit log; the ADAPTIVE warning-banner render path (`/api/warnings`) now resolves scope with `allow_dns=False` so a settings-page load can't block up to the DNS timeout on a synchronous getaddrinfo for a URL-engine primary; `CONFIGURATION.md` was regenerated so its Settings List documents the egress keys and their `LDR_*` env vars (the hand-written prose, which the on-merge auto-regeneration would have wiped, moved into the hand-maintained egress-modes.md with the cross-link fixed); plus a missing `_retriever_is_local` return annotation and an added BOTH-scope allow test. 3 regression tests added. ([#4300](https://github.com/LearningCircuit/local-deep-research/pull/4300)) - FAISS RAG indexes are now loaded through a restricted unpickler that only permits the two classes a legitimate docstore contains, instead of `FAISS.load_local(..., allow_dangerous_deserialization=True)`. A tampered `index.pkl` (the docstore pickle that is actually deserialized) can no longer execute arbitrary code on load β€” closing the pickle-deserialization RCE for every case, including a first-encounter index with no integrity record and a `.pkl`-only swap that leaves the `.faiss` checksum unchanged. The fix needs no integrity record, schema change, or re-indexing. The loader is fail-closed: if a future LangChain/Pydantic upgrade changes the docstore pickle format to use other classes, affected indexes raise `UnpicklingError` on load (and are quarantined and rebuilt) rather than silently deserializing β€” recover by re-indexing the affected collection, or by widening the loader's allow-list to the new class. ([#4632](https://github.com/LearningCircuit/local-deep-research/pull/4632)) - **WebSocket/Socket.IO connections now default to same-origin only.** When `LDR_SECURITY_WEBSOCKET_ALLOWED_ORIGINS` is unset or empty, cross-origin WebSocket connections are rejected instead of being allowed from any origin β€” closing a Cross-Site WebSocket Hijacking gap as defense-in-depth (the session cookie's `SameSite=Lax` already blocked the classic cross-site case) and matching the same-origin default already used for HTTP CORS. To run a cross-origin front-end, set `LDR_SECURITY_WEBSOCKET_ALLOWED_ORIGINS` (and `LDR_SECURITY_CORS_ALLOWED_ORIGINS`) to its origin. If you terminate TLS at a reverse proxy, forward `X-Forwarded-Proto` so the same-origin check sees `https` β€” otherwise the WebSocket handshake is rejected (see the troubleshooting guide). Set `*` to restore the previous allow-all behavior on trusted local/dev networks. A rejected WebSocket handshake is now logged with a warning naming the origin and how to allow it, so a misconfigured origin is diagnosable instead of a silently frozen progress UI. ([#4807](https://github.com/LearningCircuit/local-deep-research/pull/4807)) - **Local file/RAG path validation no longer 500s or blocks legitimate paths.** Browsing to a restricted path on a non-root deployment returned a 500 (an uncaught permission error from a symlink pre-check) instead of a clean "invalid path" response, and legitimately symlinked index folders (Docker/Kubernetes bind mounts, macOS `/tmp`) were wrongly rejected; both are fixed, and the system-directory block now also holds on macOS (where `/etc` is itself a symlink to `/private/etc`). Separately, a `LDR_DATA_DIR` path containing an apostrophe (e.g. `/home/O'Brien/ldr`) is now fully supported: it no longer crashes startup, and encrypted-database backups handle it too (the apostrophe is safely escaped in the backup's `ATTACH DATABASE` statement). ([#4808](https://github.com/LearningCircuit/local-deep-research/pull/4808)) - **Restricted-directory access blocks no longer log the user's path.** When local file/RAG path validation blocks access to a system directory, the error log now names only which restricted directory was hit (e.g. `/etc`) instead of the user's full resolved path, which could contain a username. ([#4820](https://github.com/LearningCircuit/local-deep-research/pull/4820)) - **Local-folder RAG indexing no longer logs the path you submitted when it's rejected.** A failed path validation on the local-folder indexing endpoint logged the raw submitted path (which can contain a username); it now logs a generic "Path validation failed" message instead. The `Invalid path` response is unchanged. ([#4825](https://github.com/LearningCircuit/local-deep-research/pull/4825)) - **Local-folder RAG indexing logs the folder/file name instead of the full path.** The "indexing complete" log and the per-file indexing-error log now record only the folder/file basename rather than the full filesystem path (which can contain a username), while keeping the same diagnostic detail. ([#4831](https://github.com/LearningCircuit/local-deep-research/pull/4831)) - **API error responses no longer leak raw exception text or server config (CWE-209).** Several response paths embedded internal exception detail into messages returned to the client: the `/api/news/*` endpoints (database/SQLAlchemy errors), the start-research and news-subscription egress prechecks (raw `ValueError`s), the research failure handler (unrecognized exceptions *and* LLM/search/provider "configuration error" detail β€” including server-level endpoints and file paths, since settings can come from `LDR_*` environment overrides) via `/api/research//status` and the persisted error report, and the benchmark status endpoint (`GET /benchmark/api/status/`, which returned a failed run's raw `str(e)`). All now return generic, category-appropriate messages with an actionable hint where one applies, while the full cause is retained server-side in the logs β€” hardening shared/multi-user deployments where a non-admin user must not see another tenant's or the server's internal configuration. ([#4843](https://github.com/LearningCircuit/local-deep-research/pull/4843)) - Local-folder RAG indexing now validates the supplied file glob patterns against an allowlist, rejecting any pattern that could escape the indexed folder (e.g. `../../etc/*` or an absolute `/etc/*`). Previously only the base folder was validated, so on multi-tenant/shared deployments a crafted pattern could read server-side files into the requesting user's library. ([#4846](https://github.com/LearningCircuit/local-deep-research/pull/4846)) - Local-folder RAG indexing now confines globbed matches to the indexed folder: a symlink inside the folder that points outside it (a linked file, or a linked directory recursive globbing descends into) is skipped rather than read. Previously such a symlink could expose files outside the folder, since `glob` follows symlinks and only the base folder was validated. Complements the glob-pattern validation in #4846; together they close the folder-escape surface on this endpoint. ([#4848](https://github.com/LearningCircuit/local-deep-research/pull/4848)) - **`GET /history/report/` no longer leaks the settings snapshot (API keys).** The report route returned the persisted `research_meta` wholesale in its response metadata, and `research_meta` includes `settings_snapshot` β€” which holds all application settings, including API keys, tokens, and base URLs. It now strips `settings_snapshot` via the same `strip_settings_snapshot()` helper its four sibling routes already use, while preserving every other (non-sensitive) metadata field the report view needs. ([#4853](https://github.com/LearningCircuit/local-deep-research/pull/4853)) - Symlink-loop confinement for RAG local indexing now works on Python 3.13+. The check relied on `Path.resolve()` raising on a symlink loop, which newer Python no longer does; it now resolves with `strict=True` so a planted in-base symlink loop is correctly skipped rather than silently kept. ([#4864](https://github.com/LearningCircuit/local-deep-research/pull/4864)) - **Benchmark YAML downloads no longer leak the evaluation `endpoint_url` in the default export.** The default (summary) download is meant to be safe to share, but it still emitted the evaluator's `endpoint_url`, which can be a private/internal host. It's now gated behind the existing **Export β†’ "Include settings snapshot"** opt-in β€” matching the settings-snapshot gating β€” so the default summary stays shareable and reproducibility-minded users still get it on demand. - **Closed three remaining password-leak paths in the settings module.** `GET /settings/api/` and `GET /settings/api/bulk` now redact password-typed values to `[REDACTED]` (matching the bulk `/settings/api` endpoint redacted in the previous release). `POST /save_settings` (the JS-disabled form-encoded fallback) now treats an empty value for a password setting as a no-op, so no submission path can wipe a stored API key with empty string. - Add a process-wide PEP 578 `sys.audit` hook (`security/egress_audit_hook.py`) that gates `socket.connect` against the active EgressContext. This is the secondary line of defense β€” every explicit PEP we wired (ContentFetcher, evaluate_llm_endpoint, MCP download_content, journal_reputation_filter, …) remains the primary line and still fires first. The hook catches what those PEPs cannot: a third-party library that opens its own connection, a new code path added without policy awareness, a langchain tool registered by an MCP server, prompt-injection steering a tool into raw `requests.get`, or anything else that ultimately calls `socket.connect`. The hook is installed once on first import of `local_deep_research.security` (idempotent β€” PEP 578 hooks cannot be removed) and is INACTIVE by default: with no `EgressContext` registered on the current thread, every connect passes through unmodified, so test runners, import-time helpers, and scripts that touch a socket are unaffected. Workers opt in by calling `set_active_context(ctx)` (or using the `active_egress_context` context manager); the research worker in `web/services/research_service.py` now does this after building the snapshot, and the centralized `database/thread_local_session._ThreadCleanup` exit handler clears it on worker shutdown so a pooled thread cannot leak one run's scope into the next task. The hook gates only AF_INET / AF_INET6 β€” AF_UNIX, AF_NETLINK and friends pass through. It does NOT defend against an adversary with code execution in the LDR process (they can clear the active context or add a passthrough hook themselves); for that, layer OS-level controls per SECURITY.md. 18 unit tests in `tests/security/test_egress_audit_hook.py` pin the contract: install idempotency, get/set/clear, context-manager cleanup on exception, per-scope behaviour against real raw sockets (PRIVATE_ONLY blocks 8.8.8.8 and permits 127.0.0.1; PUBLIC_ONLY mirrors; STRICT blocks public hosts; BOTH allows either), IPv6 loopback bracket handling, AF_UNIX pass-through, and per-thread isolation under concurrency. - Bumped the Docker base image from python:3.14.5-slim to python:3.14.6-slim, picking up the upstream CPython fixes for CVE-2026-9669 (bz2 decompressor reuse out-of-bounds write), CVE-2026-7774 (tarfile data_filter path-traversal bypass), and CVE-2026-3276 (unicodedata.normalize CPU exhaustion). Also suppressed three base-image CVEs with no fix available in Debian trixie (graphite2 CVE-2026-50593, expat CVE-2026-50219, perl HTTP::Tiny CVE-2026-7010 β€” none reachable in this container) in .grype.yaml, and removed five suppressions for python CVEs that the 3.14.5/3.14.6 releases fixed. - Closed a residual SQLCipher-password leak left after the #4182 sweep: `open_user_database` redacted the migration-failure log line but still re-raised `DatabaseInitializationError` with the unredacted original error embedded in its message and chained via `from init_err`. The caller (`thread_local_session`) logs that typed error with `logger.exception`, which re-rendered the chain β€” and the password its frame locals carry under `diagnose=True` β€” defeating the redaction. The typed error now carries the redacted message and breaks the chain with `from None` (ADR-0003). See #4182. - Closed another `diagnose=True` frame-locals leak of the SQLCipher master password from the #4182 sweep: `ThreadSafeMetricsWriter.get_session` opened a per-thread metrics session with `password` live in the frame and logged failures with `logger.exception`, so a rendered traceback could persist the plaintext password (unrecoverable β€” TRUST.md Β§5). It now logs with `logger.warning` (no traceback); the exception still propagates to the caller via `raise`, so nothing is swallowed. The module is added to the `test_password_redaction_invariant` allow-list so CI permanently blocks any regression. See #4182. - Closed the last `diagnose=True` frame-locals leak of the SQLCipher master password from the #4182 sweep: the two consumers of `DatabaseInitializationError` β€” `thread_local_session.get_session` and `ResourceStatusTracker.__init__` β€” caught the error and logged it with `logger.exception` while `password` was a live local in the frame, so a rendered traceback could persist the plaintext password (unrecoverable β€” TRUST.md Β§5). Both now log with `logger.warning` (no traceback); the redacted detail is already logged at the raise site. The two modules are added to the `test_password_redaction_invariant` allow-list so CI permanently blocks any regression. See #4182. - Completed the #4182 logging chokepoint: the optional file sink (`LDR_ENABLE_FILE_LOGGING`) now also runs with `diagnose=False`, matching the database and frontend sinks. Previously it still honored `LDR_LOGURU_DIAGNOSE`, so an operator with debug + diagnose + file logging all enabled could persist frame-local credentials (including the unrecoverable SQLCipher master password β€” TRUST.md Β§5) into an unencrypted log file. Frame-local exception dumps now render only to the ephemeral stderr sink; no persisted or shipped sink (DB, browser, file) ever renders them. See #4182. - Completed the analytics-page innerHTML hardening pass: coerced the `frequency_rank` value (`#N` domain rank badge) in `link_analytics.html` with `Number()` β€” the one numeric sibling left un-coerced next to `usageCount`/`usagePercentage`/`researchDiversity` β€” and escaped `research_id` in `cost_analytics.html`'s "most expensive research" list (`encodeURIComponent()` in the `href`, `window.escapeHtml()` in the link text, mirroring `star_reviews.html`). Both are defense-in-depth on raw `${...}` interpolation into `innerHTML` with no DOMPurify barrier; neither is currently exploitable (rank is a server-side int, `research_id` is a `uuid4` and the cost-analytics render path is presently disabled), but together they make the PR's "across analytics pages" scope complete. - Egress policy hardening (Round 7 review): close four PEP gaps surfaced by a 120-agent re-audit and apply the same fix to four sibling sites. (1) LLM gate at config/llm_config.py now uses a known-local allow-list (`ollama`/`lmstudio`/`llamacpp`) when no snapshot is supplied β€” previously the gate silently no-op'd, letting cloud LLMs instantiate from snapshot-less callers under `llm.require_local_endpoint=true`. (2) `JournalReputationFilter` forwards `settings_snapshot` to `get_llm()` and `create_default` lets `PolicyDeniedError` propagate instead of silently returning None. (3) MCP `_discover_mcp_tools` / `_execute_mcp_tool` block under STRICT / PRIVATE_ONLY (and on absent snapshot), with a policy_audit log and a UI progress signal. (4) `_execute_download_content` narrows its bare `except Exception` so corrupted scope values fail closed instead of dropping to SSRF-only. The same bare-except fail-open pattern is fixed consistently in `notifications/manager.py`, `research_library/services/download_service.py` (paired with a `_policy_locked` short-circuit in the URL check), `web_search_engines/search_engine_base.py` (disables `include_full_content` when policy can't be evaluated), and `web/routes/research_routes.py` (STRICT + meta-picker now surfaces as 400 at run-start). Adds a Flask `PolicyDeniedError` handler so denials escaping a synchronous PEP return a clean 400 with the decision reason. Regression tests: parametrized no-snapshot fail-closed for every cloud provider plus ambiguous (`openai_endpoint`) and hypothetical (`groq`, `mistral`, `cohere`) entries; allow-list passes for the three known-local providers; MCP scope gate (STRICT/PRIVATE_ONLY/no-snapshot/corrupted policy) verifies the connection manager is never touched and the empty list is cached; download_content refuses without constructing ContentFetcher; journal_reputation_filter forwards snapshot and propagates PolicyDeniedError. - Egress-policy follow-ups after the 5Γ—20 review: (1) ContentFetcher now relaxes a downloader's `SafeSession` to allow private IPs under `PRIVATE_ONLY`, mirroring `policy_aware_validate_url`, so a private/lab URL the policy already approved is no longer rejected by the downloader's own strict SSRF re-validation; (2) the PEP-578 audit-hook backstop is now re-armed in the `NewsAggregationStrategy` analysis ThreadPoolExecutor worker (threading.local isn't inherited by pool workers) and in the document scheduler's worker thread (built from the user's saved settings), so scheduled downloads and news-analysis LLM calls keep defense-in-depth parity under `PRIVATE_ONLY`/`STRICT`. The primary snapshot-based PEPs already gated these paths; this restores the secondary net. (MCP search runs synchronously on the already-armed strategy thread, so it needed no change.) - Extracted `updateEnhancedDomainList` from the `link_analytics.html` inline script into `static/js/pages/link_analytics_render.js` (surgical extraction mirroring PR #4584) so it can be tested in isolation. Added 7 Vitest XSS regression tests at `tests/js/pages/link-analytics-xss.test.js` covering the escape sites fixed in #3095 (Number()-coercion of `research_diversity`/`usage_count`/`usage_percentage`, `escapeHtml` of research-link queries and classification fields, `encodeURI`/`encodeURIComponent` of domain names and research IDs, and the inline-script wrapper pattern at `link_analytics.html:797-802`). These tests run in PR CI via the existing Vitest job. Added a new `link-analytics` Puppeteer shard for full-page verification of `/metrics/links` in the release pipeline (strict-mode only), with runtime assertions that no `