### 🔒 Security - Raw exception variables (`e`, `exc`, etc.) are no longer interpolated directly into `logger.exception()` messages across all LLM provider, embedding provider, and web search engine paths; each catch site now scrubs the message through `sanitize_error_message()`/`redact_secrets()` before logging, preventing error text from leaking API keys, credentials, or internal URL structure into production logs. Scrubbed exception log lines now include the exception class name so exceptions that stringify to an empty message still produce useful diagnostics. A new `check-sensitive-logging` pre-commit hook enforces this rule going forward, rejecting direct exception-variable interpolation in the secure-logging directories and flagging `exc_info=True` on `warning`/`error`/`critical` calls. ([#4888](https://github.com/LearningCircuit/local-deep-research/pull/4888)) - `NewsAPIException.to_dict()` now redacts credential shapes from the fields it serializes to the client through the two `@errorhandler(NewsAPIException)` handlers: the human-readable `message` goes through the central `sanitize_error_for_client()`, and string leaves of `details` go through `sanitize_error_message()`. This is a defence-in-depth backstop at the response boundary — the primary defence stays at the `news/api.py` raise sites (curated generic messages, #4843) — so a future raise site, subclass, or external caller that lets a credential-bearing string into `message` or `details` (e.g. the caller-supplied search `query` forwarded in `details`) has it redacted before it ships. Redaction is credential-shape based (Bearer/`Authorization`, `?api_key=`-style params, known token prefixes, `http(s)` URL userinfo), so legitimate text is unchanged; `error_code` / `status_code` are left untouched. It does not attempt to catch DSN userinfo (`postgresql://user:pass@`), SQL, or filesystem paths — those remain the responsibility of the raise-site discipline. ([#4931](https://github.com/LearningCircuit/local-deep-research/pull/4931)) - The central credential sanitizer (`sanitize_error_message` / `sanitize_error_for_client`) now redacts URL userinfo credentials in *any* scheme (case-insensitive), not just `http(s)`. Raw **URL-form** database connection errors — SQLAlchemy `dialect+driver` DSNs (`postgresql+psycopg2://user:pass@host`, `mysql+pymysql://…`, `mongodb+srv://…`), plain DB schemes, and password-only DSNs (`redis://:pass@host`) — previously leaked their password through any surfaced exception message; the password is now replaced with `[REDACTED]`. Credential-less DSNs with a port (`postgresql://host:5432/db`) and all existing `http(s)` behavior are unchanged. Key=value DSNs (e.g. pyodbc `Server=...;Pwd=...`) have no `://` and remain out of scope for a userinfo regex. This benefits every consumer of the central sanitizer (download service, fetch tool, agent strategies, search engines, and the news error-response backstop). ([#4933](https://github.com/LearningCircuit/local-deep-research/pull/4933)) - `WebAPIException.to_dict()` now redacts credential shapes from the fields it serializes to the client (`message` via `sanitize_error_for_client()`, `details` string leaves via `sanitize_error_message()`), mirroring the `NewsAPIException` backstop. This is a defence-in-depth measure at the response boundary so that a future raise site, subclass, or external caller that lets a credential-bearing string into `message` or `details` has it redacted before it ships. Legitimate text is unchanged, and `status` / `error_code` / `status_code` are left untouched. ([#4937](https://github.com/LearningCircuit/local-deep-research/pull/4937)) - The egress guardrail now enforces a two-axis (data-sensitivity × destination-exposure) admissibility rule at the start of a research run (ADR-0007): the run is refused when a sensitive source (a private collection/library/store) would reach an exposing sink (a public search engine or a cloud LLM/embeddings provider), in addition to the existing scope checks. Override by setting Egress Scope to **Unprotected**, marking the collection public, or trusting the destination. ### ✨ New Features - **New Zotero integration: import a Zotero library or collection into your document library, with optional background auto-sync.** Add your Zotero API key, library type/ID and (optionally) a collection key under **Settings → Zotero**, then use the new **Library → Zotero** page to test the connection, list collections, and **Sync now**. Imported papers are stored, text-extracted and RAG-indexed like any other library document, so they become searchable during research via the collection search engine — no separate search engine to configure. Sync is incremental (new, changed and removed items are reconciled), and items without an attached PDF are imported as metadata/abstract text by default (toggle off to import PDFs only). Enable **Auto-Sync** to refresh in the background on a configurable interval. ([#4723](https://github.com/LearningCircuit/local-deep-research/pull/4723)) - Zotero integration polish from first real-world testing: standalone PDF attachments (PDFs added without a parent item) now import as documents; the library ID is auto-detected from the API key (usernames resolve too); manual syncs re-examine previously skipped items; imports appear in the main Library view; a live progress bar tracks syncs; the config page autosaves with essentials-first layout and runs the connection test on saving a key; friendlier defaults (text-only PDF storage, integration enabled once a key is set) and actionable error messages. ([#4960](https://github.com/LearningCircuit/local-deep-research/pull/4960)) - Add a per-category entry counter next to each filter button in the Research Logs panel (All, Milestones, Info, Warning, Errors). Counts reflect entries currently rendered in the DOM and update on insert, prune, and batch load, so users can see at a glance which categories still have entries when the global DOM cap is hit. - Add an **Unprotected** egress scope — an explicit opt-out that disables the egress-scope restrictions for a run (the hard SSRF and cloud-metadata blocks still apply), surfaced with a light-red panel and a non-dismissible "protection disabled" banner. The rarely-used **Both** scope is retired: it is removed from the selector, and existing saved `both` values are migrated to **Adaptive** (a residual value is also coerced to Adaptive at read time). If you relied on `both` to run a private collection with a cloud model, mark that collection **public** or choose **Unprotected**. - Add per-destination trust to the egress model (ADR-0007): `policy.trusted_inference_providers` and `policy.trusted_search_engines` let you mark specific off-machine LLM/embeddings providers or search engines as trusted, so the two-axis classification treats them as *contained* (e.g. a zero-retention enterprise Anthropic endpoint, or a self-hosted Elasticsearch/Paperless on a public hostname). A banner surfaces active trust entries. ### 🐛 Bug Fixes - Registration no longer leaves a bricked account behind: if creating the encrypted user database fails after the auth row is committed, both the orphaned auth row and any partial on-disk database files (including the per-database salt) are now cleaned up, so the username is genuinely reusable on retry. ([#4934](https://github.com/LearningCircuit/local-deep-research/pull/4934)) - The settings write API now rejects malformed keys (a trailing dot like `local_search_chunk_size.`, a leading dot, an empty `..` segment, or blank/whitespace) with HTTP 400 instead of silently persisting them. Such rows made `get_setting()` return a `{"": value}` wrapper dict that rendered as `[object Object]` on settings pages (#4840). The manager (`set_setting`, `create_or_update_setting`) also refuses to create malformed keys, and `import_settings` skips them so a corrupted export can't reintroduce them. Complements the read-side fix in #4852. ([#4935](https://github.com/LearningCircuit/local-deep-research/pull/4935)) - Registration now recovers from an orphaned per-database salt file (left with no matching database by a prior interrupted registration), so a username that was previously stuck as un-registerable can be registered again. ([#4942](https://github.com/LearningCircuit/local-deep-research/pull/4942)) - Both settings prefix-read paths — `SettingsManager.get_setting` (DB) and `get_setting_from_snapshot` (the snapshot path used by research threads) — now filter out malformed rows (e.g. a legacy `foo..` or `foo. ` key), so a stray malformed row can no longer turn a leaf read into a `{".": value}` wrapper dict that renders as `[object Object]`. Completes the read side of #4840 beyond #4852's exact single-trailing-dot exclusion; existing malformed rows remain harmless and are not deleted. ([#4946](https://github.com/LearningCircuit/local-deep-research/pull/4946)) - Fix the 'Info' filter button in the Research Logs panel being a silent no-op. Clicking it left the panel showing every entry (warnings, errors, milestones) because the visibility helper returned true for all log types when the active filter was 'info'. The filter now narrows to info entries only, matching the button's label. - Fixed a 30–50 second visible lag between clicking **Stop** and a focused-iteration research actually stopping. `FocusedIterationStrategy.analyze_topic` now calls `self.check_termination()` at the start of every iteration, after the parallel search (before optional verification searches), and just before the final synthesis LLM call, so a cancellation request is detected within the current iteration rather than only at the next iteration's progress emit. - Fixes a subtle data-loss bug in the Research Logs panel: identical warning, error, and milestone entries were being collapsed into a single `(N×)` counter, the same way repetitive info entries like "Status: OK" already were. Collapsing diagnostic entries like that strips the recency signal — after the second identical error you can no longer tell *when* the last failure happened, and repeated retries look like a single stuck event instead of forward progress. The content-dedup scan now applies only to `info` entries (where collapsing repetitive noise is useful). Warning / error / milestone entries always render. The id-based dedup earlier in the pipeline still catches exact retransmits with the same id, so the bypass is strictly about *content-similar-but-distinct-events* getting the same timestamp. ### 📝 Other Changes - Fixed broken copy-paste examples in the README and docs (benchmark CLI module path, `rate_limiting reset --engine`, nonexistent `openclaw` search engine) plus two dead external links, and added a pre-commit check that keeps README links, anchors, and examples in sync with the codebase. ([#4950](https://github.com/LearningCircuit/local-deep-research/pull/4950))