Skip to content

docs(book): add the KnowledgeLens assistant widget, on opt-in - #11390

Open
alchemydc wants to merge 2 commits into
mainfrom
docs/knowledgelens-widget
Open

docs(book): add the KnowledgeLens assistant widget, on opt-in#11390
alchemydc wants to merge 2 commits into
mainfrom
docs/knowledgelens-widget

Conversation

@alchemydc

@alchemydc alchemydc commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Motivation

Adds the KnowledgeLens documentation assistant to the Zebra book at zebra.zfnd.org, so readers can ask questions about the docs from any page.

Closes #11395.

Solution

The widget is loaded on opt-in only. A first-party button ("Ask the docs", styled from mdBook's own theme variables so it follows every book theme) sits bottom-right; the vendor's script is injected only when a reader clicks it, and the opt-in is remembered in first-party localStorage under zebra.lens.opt-in, which never leaves the browser. A reader who never clicks makes no contact with the vendor at all — the built pages contain zero references to knowledgelens.ai.

This is deliberate rather than incidental. Left to itself the vendor bundle boots on page load, writes a persistent UUID to localStorage as lens.distinctId.v1, and beacons every page view to its /analytics/track endpoint, with exit beacons on pagehide and beforeunload. It honours neither Do Not Track nor Global Privacy Control, and offers no opt-out. That is a poor default for Zebra's readers, and the book currently loads no third-party JavaScript at all, so this would have been the first executing third-party code on the site.

So the loader sets window.__lensManualBoot = true to suppress the automatic boot, and starts the widget by hand with window.Lens.boot({}). The bundle initialises its analytics and persistent id only on the no-argument boot path, so passing a config object leaves that machinery uninitialised while the widget itself works normally. The full analysis behind this is in the appendix below.

On the mdBook side: additional-js renders entries as a bare <script src="..."> with no attributes, so the vendor's HTML snippet — which carries its entire configuration in data-* attributes — can't be used through that hook. The only alternative is overriding the theme's index.hbs, which would mean vendoring mdBook's default template; book.yml floats on mdbook-version: ~0.5, so a vendored copy would drift from upstream. Building the script element in JS avoids that.

The widget is configured to open straight into chat rather than requiring the reader to drag-select a region of the page first.

The knowledge-base id is committed rather than injected from a CI secret, and lens-init.js carries a comment explaining why: the file is served to every visitor, so the id is public by design, and access is controlled by the allowed-origins list in the Lens dashboard. Putting it in a CI secret would protect nothing, and a substitution step that silently no-opped would leave the published book querying the vendor's demo_kb default.

Tests

Built with mdbook build book and confirmed the loader and stylesheet are copied to the site root and referenced from both root and nested pages with correct relative paths, and that no vendor URL appears in any delivered page.

Exercised the widget in a browser against a local mdbook serve: the opt-in button renders and themes correctly, the script loads on click, the panel opens, and questions are answered against the knowledge base.

Reviewers can confirm the privacy behaviour in DevTools: no requests to knowledgelens.ai and no lens.distinctId.v1 key before opting in; after opting in and asking a question, no POST to /analytics/track. Note that the suppression relies on vendor internals rather than a documented flag — see Follow-up Work.

Verified the deployed origin will work: a preflight from https://zebra.zfnd.org is accepted by the API, and a query carrying that origin's pageUrl succeeds.

No Rust code is touched, so the workspace build and test suites are unaffected.

Specifications & References

Docs deploy via .github/workflows/book.yml to GitHub Pages. Only mdBook-rendered pages are affected — the rustdoc pages under /internal/ and the benchmark dashboard under /dev/bench/ are unchanged.

The appendix below is a full privacy review of the vendor bundle, covering what it collects by default and why this PR does not use the vendor's stock snippet.

Follow-up Work

Worth raising with the vendor: a documented flag to disable telemetry, so we don't depend on __lensManualBoot and the argument-dependent boot() behaviour, both undocumented and on an unversioned CDN URL that could change at any time; a versioned URL so the script can carry a subresource-integrity hash; and clarity on per-knowledge-base rate limits, since the embed id is public and the origin allowlist does not constrain non-browser clients.

The unpinned CDN is a knowingly accepted risk for now, raised as F-266280 by the automated audit on this PR. The candidate fix is to vendor the bundle into book/ and serve it first-party, as book/mermaid.min.js already is; that is deferred pending answers from the vendor on whether their terms permit redistribution and whether they will publish a versioned URL.

The team may also want to decide whether the book or ZF's privacy policy should carry a short note about the assistant and what it sends when used.

Testing note for anyone reproducing locally: the API sits behind a WAF that rejects request bodies containing a localhost or 127.0.0.1 URL, and the widget always sends the current pageUrl. Serving the book on localhost therefore fails every query with an edge 403 that the browser reports as a misleading CORS error. Test under a non-localhost hostname added to the dashboard's allowed origins.

AI Disclosure

  • No AI tools were used in this PR
  • AI tools were used: Claude Code, for the loader, the privacy analysis of the vendor bundle, and this description.

PR Checklist

  • The PR title follows conventional commits format: type(scope): description
  • The PR follows the contribution guidelines.
  • This change was discussed in an issue or with the team beforehand.
  • The solution is tested.
  • The documentation and changelogs are up to date.

Appendix: privacy review of the vendor bundle

Reviewed for the proposed integration of the KnowledgeLens documentation assistant into https://zebra.zfnd.org. The question was whether the vendor's default embed is acceptable for Zebra's readership, which skews strongly privacy-motivated.

Method. Findings come from reading the shipped bundle at https://cdn.knowledgelens.ai/lens.js (~517 KB, minified) and from probing the API directly with curl. Nothing here is taken from vendor marketing or documentation. The bundle is unversioned and unpinned, so it can change at any time; everything below describes the build reviewed on 2026-09-03.

Summary

The vendor's default embed places a persistent pseudonymous identifier and an analytics beacon on every page of the book, for every reader, whether or not they ever use the widget. That is not acceptable as a default here. It is avoidable: the bundle can be loaded on demand and started in a mode that never initialises its analytics, which is what the proposed integration does.

What the default embed does on a plain page view

With the vendor's stock snippet, and with no user interaction at all, loading any page:

  1. Fetches lens.js from cdn.knowledgelens.ai. The vendor's CDN sees the reader's IP address and User-Agent.
  2. Boots immediately, without waiting for the widget to be opened.
  3. Writes a persistent random UUID to localStorage under the key lens.distinctId.v1. It has no expiry and is stable across pages and across return visits.
  4. POSTs to /embed/token to exchange the embed id for a session token.
  5. Queues a widget.loaded event and POSTs it to /analytics/track as {events, distinctId, sessionId}. The queue flushes on a 10-second timer and again on visibilitychange, pagehide and beforeunload using keepalive, so exit beacons fire as well.

The net effect: the vendor receives a durable pseudonymous identity, an IP address, and a timestamp for every page view by every reader of the book.

What is and is not in the analytics payload

Analytics events carry metadata only. The full event set is widget.loaded, activated, closed, session_resumed, selection_started / cancelled / completed, question_asked, answer_received, answer_error, citation_clicked, source_opened / expanded, voice_started / completed / error, tutorial_started / completed, first_run_hint_shown / dismissed, and two enrollment-offer events. Their properties are things like latencyMs, sourceCount, elementCount, method: "drag" | "click", and inputMethod: "voice" | "typed".

No question text, no selected text, and no page URL appear in the analytics body. Because fetch defaults to strict-origin-when-cross-origin, the Referer on those calls is https://zebra.zfnd.org/ — the origin, not the path.

So from analytics alone the vendor learns "this persistent identity, at this IP, read the Zebra book at these times" — not which pages were read.

Page-level detail is disclosed only when a reader actually asks a question: /embed/query carries the question text and the full pageUrl, plus selectedText and surroundingContext. In the proposed configuration defaultSelection is false, so selectedText is empty and surroundingContext is null, and page content is not transmitted.

What it does not do

Checked specifically, because these claims cut both ways:

  • No fingerprinting. The fingerprint identifier in the bundle is a content hash of agent-run resolutions, unrelated to browser fingerprinting. canvas appears only as an HTML tag name in a sanitiser allowlist. navigator.userAgent is read solely to choose between showing a Command or Control key in the hotkey hint, and navigator.language only to set the speech-recognition locale.
  • No cookies, no sessionStorage, no IndexedDB. Exactly two localStorage keys: lens.distinctId.v1 and an onboarding-seen flag.
  • No third-party ad or analytics SDKs are bundled; telemetry goes to the vendor's own endpoint.

What it does not honour

There is no handling of Do Not Track or Global Privacy Control — zero occurrences of either in the bundle — and no consent gate, opt-out, or configuration flag to disable telemetry.

Two smaller findings

Voice input. The widget can dictate questions via the browser's SpeechRecognition API, which on Chrome streams audio to Google for transcription. It is gated behind an open panel and a microphone permission prompt, so it is not a drive-by capture, but it is a third-party audio disclosure distinct from the vendor.

Hotkey capture. Ctrl/Cmd+K is captured page-wide with a capturing keydown listener that calls preventDefault(), overriding the browser's own shortcut. It correctly passes the key through when focus is in an input, textarea, or contenteditable.

Change against today's baseline

The book currently loads no third-party JavaScript. The front page pulls CI and licence badges from github.com, codecov.io and img.shields.io, plus the ZFND logo from zfnd.org, but those are passive images: they leak IP and User-Agent, and execute no code and set no storage.

The default embed would therefore be the first executing third-party code on the site, on every page, for an audience that includes people reading over Tor or a VPN specifically so that a third party does not learn they are interested in Zcash tooling.

The mitigation used in the proposed integration

The bundle's boot path contains an exploitable guard:

function NS(e){ window.__lensBooted=!0; let t={...jS(),...e};
  !t.preview && !e && (ap({apiBase:t.apiBase, embedSecret:t.kb}), K(`widget.loaded`,{theme:t.theme}));
  ...

ap() is what mints the persistent id and starts the beacon loop, and it runs only when boot is called with no argument. Two consequences:

  • Setting window.__lensManualBoot = true before the script loads suppresses the automatic boot entirely.
  • Calling window.Lens.boot({}) afterwards — an empty object is enough, with the real configuration still read from the script's data-* attributes — leaves ap() uncalled. The event recorder and the flusher both short-circuit on the uninitialised flag, so no lens.distinctId.v1 is created and no /analytics/track request is ever made.

The integration combines this with click-to-load: a first-party button injects lens.js only when a reader asks for it. A reader who never clicks makes no contact with the vendor at all — confirmed by the built pages containing zero references to knowledgelens.ai. The opt-in is remembered in first-party localStorage under zebra.lens.opt-in, which never leaves the browser.

Reviewers can confirm the analytics suppression in DevTools: after opting in and asking a question, there should be no POST to /analytics/track and no lens.distinctId.v1 key.

Residual risks

The mitigation relies on undocumented internals. __lensManualBoot and the argument-dependent behaviour of boot() are not documented by the vendor. A CDN push could silently restore the analytics path. We should ask for a supported flag, for example data-analytics="false".

No subresource integrity is possible. https://cdn.knowledgelens.ai/lens.js is unversioned, so it cannot carry an integrity hash. Whatever the vendor publishes to that path executes on zebra.zfnd.org with full DOM access. A versioned URL would let us pin and add SRI, and is worth requesting.

The origin allowlist is not a security control against non-browser clients. The API accepts an embed-token exchange from any client that sets the Origin header to an allowlisted value; this was confirmed with curl from a server. Since the embed id is public by design, anyone can mint tokens and run queries against ZF's knowledge base on ZF's quota. Worth asking the vendor about per-knowledge-base rate limits and spend caps.

Consent posture. A persistent identifier in localStorage is cookie-equivalent under ePrivacy. The opt-in design largely sidesteps this, since nothing is set or sent until the reader acts, but whether ZF's privacy policy should mention the assistant is a question for the team.

Recommendations

  1. Ship the assistant opt-in only, with manual boot, as implemented — not the vendor's stock snippet.
  2. Verify in a browser after any vendor change that no /analytics/track traffic appears, since the CDN is unversioned.
  3. Ask the vendor for a documented telemetry-off flag, a versioned CDN URL so SRI is possible, and clarity on per-knowledge-base rate limits.
  4. Decide whether the book and ZF's privacy policy should carry a short note about the assistant and what it sends when used.

Reproduction notes

Anyone re-running this analysis locally should know that the API sits behind a WAF that rejects request bodies containing a localhost or 127.0.0.1 URL, and the widget always sends the current pageUrl. Serving the book on localhost therefore fails every query with an edge 403 that the browser reports as a misleading CORS error. Test under a non-localhost hostname added to the dashboard's allowed origins.

The vendor bundle boots on page load, mints a persistent id in localStorage
and beacons every page view to its analytics endpoint. Instead the loader
renders a first-party button and injects the script only when a reader asks
for it, then starts it via `Lens.boot({})`, whose argument path skips the
analytics and id initialisation entirely.

mdBook's `additional-js` emits a bare `<script src>`, so the vendor's HTML
snippet and its data-* attributes can't be used directly.
@v12-auditor

v12-auditor Bot commented Sep 3, 2026

Copy link
Copy Markdown

Note

Complete: Audit complete. V12 found three issues worth reviewing.

Open the full results here.

FindingSeverityDetails
F-266280 🟠 High
Unpinned vendor script enables origin takeover

A compromised CDN response or a malicious vendor update at the unversioned https://cdn.knowledgelens.ai/lens.js URL is accepted as executable code without validation. When a reader clicks “Ask the docs,” the handler records persistent consent and calls loadLens, which creates a classic <script>, assigns the third-party URL, and appends it directly to the document. The returned bytes therefore execute in the Zebra documentation page's JavaScript realm, with access to that origin's DOM and Web Storage. The same asset is loaded automatically on every later page load while the opt-in key remains set. Because the element has no integrity value and execution is not contained in a sandboxed cross-origin iframe, neither HTTPS nor the reader's prior consent constrains a subsequently compromised or changed vendor asset.

F-266281 🟡 Medium
Persistent consent outlives user and failed loads

A click on “Ask the docs” stores the origin-wide zebra.lens.opt-in value before the vendor script has loaded or initialized successfully. The script-load error and manual-boot exception paths only re-enable the button; neither removes the stored value. On every later page load in the same browser profile, hasOptedIn() consequently calls loadLens(false) without another interaction, including after an initialization that never succeeded and during use by another person sharing the profile. The file provides no expiry, revocation, or deletion path, so this authorization remains effective until browser storage is cleared or evicted externally.

F-266282 🔵 Low
Framed button permits clickjacked opt-in

A cross-origin site that frames a Zebra book page can position the iframe so an unrelated-looking user click lands on #lens-opt-in. The genuine framed click reaches the handler without any check that the page is top-level or any separate top-level confirmation. The handler immediately stores the opt-in value and calls loadLens(true), which appends the external script and triggers the KnowledgeLens fetch and execution. This path does not require script access to the cross-origin framed document or pre-existing same-origin code execution.

Analyzed one file, diff 0881709...a3b7707.

Record the opt-in only after the widget actually boots, so a failed load
no longer arms auto-loading on later pages, and hold it in sessionStorage
so it expires with the tab instead of persisting for whoever uses the
profile next. Skip the button entirely when framed, since a cross-origin
frame could position it under an unrelated-looking click.
@alchemydc

Copy link
Copy Markdown
Contributor Author

Thanks — two of the three are fixed in c1243f3, and the third is a knowingly accepted risk for now rather than an oversight.

F-266281 (Medium) — fixed. The persist-before-success behaviour was a genuine bug: rememberOptIn() ran before the script had loaded, so a failed load still armed auto-loading on every later page. It now runs only in the success path, after the widget has actually booted. The opt-in also moved from localStorage to sessionStorage, so it expires with the tab rather than persisting indefinitely for whoever uses the browser profile next. That covers the revocation gap too — closing the tab is the revocation path.

F-266282 (Low) — fixed. The opt-in button is no longer rendered, and the auto-load path no longer runs, unless the page is top-level. Worth noting for anyone reading this later that the practical impact was small: a clickjacked opt-in causes the victim's browser to fetch the vendor bundle, but the attacker cannot read the framed cross-origin page or recover anything from it. Separately, the site is fronted by Cloudflare, so frame-ancestors could be set there and would address this class of issue site-wide rather than per-feature; that is being raised with the people who run that config.

F-266280 (High) — accepted for now, not disputed. The finding is correct and the PR description already documented it as a residual risk. cdn.knowledgelens.ai/lens.js is unversioned, so it cannot carry an integrity hash, and a compromised or changed bundle would execute in this origin. The impact worth stating precisely: there is no auth, session, or user data on the docs origin, so there is nothing to exfiltrate, but the book documents how to install and configure a node, and a hostile bundle could alter what a reader sees there. That is why it is being treated as real rather than argued down.

The candidate fix is to vendor the bundle into book/ and serve it first-party, which makes it a pinned, reviewable artifact and removes the CDN as a live dependency. There is precedent — book/mermaid.min.js has been vendored the same way since #4359 — and the bundle is self-contained, with no dynamic imports, code-splitting, workers, or runtime references to its own CDN, so a single file is sufficient. One implementation detail if we go that route: the bundle locates its own configuration via document.querySelector("script[src*='lens.js']"), and mdBook content-hashes assets to lens-<hash>.js, so the lookup would silently fall back to the vendor's demo_kb defaults; the configuration would need to be passed explicitly to Lens.boot().

That is deferred rather than done because it needs two answers from the vendor first: whether their terms permit redistributing the bundle, and whether they will publish a versioned URL so SRI becomes possible without us taking on the update burden. Pinning a bundle means owning its staleness, which is a trade the team should make deliberately.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add MVP chat assistant to the Zebra book

1 participant