@plmbr/notebook-intelligence 5.0.0 → 5.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -159,7 +159,7 @@ NBI reloads open document tabs when their files change on disk, so edits an AI a
159
159
 
160
160
  ## Configuration
161
161
 
162
- Configure your provider, model, and API key from NBI Settings — the gear icon in the chat panel, the `/settings` chat command, or the JupyterLab command palette. For background, see the [provider blog post](https://notebook-intelligence.github.io/notebook-intelligence/blog/2025/03/05/support-for-any-llm-provider.html).
162
+ Configure your provider, model, and API key from NBI Settings — the gear icon in the chat panel, the `/settings` chat command, or the JupyterLab command palette. For background, see the [provider blog post](https://plmbr.dev/blog/archive/support-for-any-llm-provider/).
163
163
 
164
164
  <img src="media/provider-list.png" alt="Settings dialog" width=500 />
165
165
 
@@ -398,10 +398,10 @@ The feedback fires an in-process `telemetry` event. Nothing leaves the process b
398
398
 
399
399
  ## Further reading
400
400
 
401
- - [Introducing Notebook Intelligence!](https://notebook-intelligence.github.io/notebook-intelligence/blog/2025/01/08/introducing-notebook-intelligence.html)
402
- - [Building AI Extensions for JupyterLab](https://notebook-intelligence.github.io/notebook-intelligence/blog/2025/02/05/building-ai-extensions-for-jupyterlab.html)
403
- - [Building AI Agents for JupyterLab](https://notebook-intelligence.github.io/notebook-intelligence/blog/2025/02/09/building-ai-agents-for-jupyterlab.html)
404
- - [Notebook Intelligence now supports any LLM Provider and AI Model!](https://notebook-intelligence.github.io/notebook-intelligence/blog/2025/03/05/support-for-any-llm-provider.html)
401
+ - [Introducing Notebook Intelligence!](https://plmbr.dev/blog/archive/introducing-notebook-intelligence/)
402
+ - [Building AI Extensions for JupyterLab](https://plmbr.dev/blog/archive/building-ai-extensions-for-jupyterlab/)
403
+ - [Building AI Agents for JupyterLab](https://plmbr.dev/blog/archive/building-ai-agents-for-jupyterlab/)
404
+ - [Notebook Intelligence now supports any LLM Provider and AI Model!](https://plmbr.dev/blog/archive/support-for-any-llm-provider/)
405
405
 
406
406
  ## Roadmap
407
407
 
@@ -13,8 +13,9 @@ import copySvgstr from '../style/icons/copy.svg';
13
13
  import copilotSvgstr from '../style/icons/copilot.svg';
14
14
  import copilotWarningSvgstr from '../style/icons/copilot-warning.svg';
15
15
  import { VscSend, VscStopCircle, VscEye, VscEyeClosed, VscAdd, VscClose, VscHistory, VscTriangleRight, VscTriangleDown, VscSettingsGear, VscPassFilled, VscTools, VscTrash, VscThumbsup, VscThumbsdown, VscThumbsupFilled, VscThumbsdownFilled, VscCloudUpload, VscFile, VscRefresh } from './icons';
16
- import { extractLLMGeneratedCode, isDarkTheme, safeAnchorUri, writeTextToClipboard } from './utils';
16
+ import { extractLLMGeneratedCode, isDarkTheme, writeTextToClipboard } from './utils';
17
17
  import { CheckBoxItem } from './components/checkbox';
18
+ import { SafeAnchor } from './components/safe-anchor';
18
19
  import { mcpServerSettingsToEnabledState } from './components/mcp-util';
19
20
  import claudeSvgStr from '../style/icons/claude.svg';
20
21
  import { AskUserQuestion } from './components/ask-user-question';
@@ -474,17 +475,8 @@ function ChatResponse(props) {
474
475
  React.createElement("button", { className: "jp-Dialog-button jp-mod-accept jp-mod-styled", onClick: () => runCommand(item.content.commandId, item.content.args) },
475
476
  React.createElement("div", { className: "jp-Dialog-buttonLabel" }, item.content.title))));
476
477
  case ResponseStreamDataType.Anchor: {
477
- const safeUri = safeAnchorUri(item.content.uri);
478
- if (!safeUri) {
479
- return (React.createElement("div", { className: "chat-response-anchor", key: `key-${index}` },
480
- React.createElement("span", null,
481
- item.content.title,
482
- React.createElement("span", { className: "nbi-sr-only" }, " (link blocked)"))));
483
- }
484
478
  return (React.createElement("div", { className: "chat-response-anchor", key: `key-${index}` },
485
- React.createElement("a", { href: safeUri, target: "_blank", rel: "noopener noreferrer" },
486
- item.content.title,
487
- React.createElement("span", { className: "nbi-sr-only" }, " (opens in new tab)"))));
479
+ React.createElement(SafeAnchor, { href: item.content.uri }, item.content.title)));
488
480
  }
489
481
  case ResponseStreamDataType.Progress:
490
482
  // Render only the most recent progress entry, and only while
@@ -3369,28 +3361,20 @@ function GitHubCopilotLoginDialogBodyComponent(props) {
3369
3361
  ghLoginStatus === GitHubCopilotLoginStatus.NotLoggedIn && (React.createElement(React.Fragment, null,
3370
3362
  React.createElement("div", null, "Your code and data are directly transferred to GitHub Copilot as needed without storing any copies other than keeping in the process memory."),
3371
3363
  React.createElement("div", null,
3372
- React.createElement("a", { href: "https://github.com/features/copilot", target: "_blank", rel: "noopener noreferrer" },
3373
- "GitHub Copilot",
3374
- React.createElement("span", { className: "nbi-sr-only" }, " (opens in new tab)")),
3364
+ React.createElement(SafeAnchor, { href: "https://github.com/features/copilot" }, "GitHub Copilot"),
3375
3365
  ' ',
3376
3366
  "requires a subscription and it has a free tier. GitHub Copilot is subject to the",
3377
3367
  ' ',
3378
- React.createElement("a", { href: "https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features", target: "_blank", rel: "noopener noreferrer" },
3379
- "GitHub Terms for Additional Products and Features",
3380
- React.createElement("span", { className: "nbi-sr-only" }, " (opens in new tab)")),
3368
+ React.createElement(SafeAnchor, { href: "https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features" }, "GitHub Terms for Additional Products and Features"),
3381
3369
  "."),
3382
3370
  React.createElement("div", null,
3383
3371
  React.createElement("h4", null, "Privacy and terms"),
3384
3372
  "By using Notebook Intelligence with GitHub Copilot subscription you agree to",
3385
3373
  ' ',
3386
- React.createElement("a", { href: "https://docs.github.com/en/copilot/responsible-use-of-github-copilot-features/responsible-use-of-github-copilot-chat-in-your-ide", target: "_blank", rel: "noopener noreferrer" },
3387
- "GitHub Copilot chat terms",
3388
- React.createElement("span", { className: "nbi-sr-only" }, " (opens in new tab)")),
3374
+ React.createElement(SafeAnchor, { href: "https://docs.github.com/en/copilot/responsible-use-of-github-copilot-features/responsible-use-of-github-copilot-chat-in-your-ide" }, "GitHub Copilot chat terms"),
3389
3375
  ". Review the terms to understand about usage, limitations and ways to improve GitHub Copilot. Please review",
3390
3376
  ' ',
3391
- React.createElement("a", { href: "https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement", target: "_blank", rel: "noopener noreferrer" },
3392
- "Privacy Statement",
3393
- React.createElement("span", { className: "nbi-sr-only" }, " (opens in new tab)")),
3377
+ React.createElement(SafeAnchor, { href: "https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement" }, "Privacy Statement"),
3394
3378
  "."),
3395
3379
  React.createElement("div", null,
3396
3380
  React.createElement("button", { className: "jp-Dialog-button jp-mod-accept jp-mod-reject jp-mod-styled", onClick: handleLoginClick },
@@ -3415,7 +3399,7 @@ function GitHubCopilotLoginDialogBodyComponent(props) {
3415
3399
  ' ',
3416
3400
  "and enter at",
3417
3401
  ' ',
3418
- React.createElement("a", { href: deviceActivationURL, target: "_blank", rel: "noopener noreferrer" }, deviceActivationURL),
3402
+ React.createElement(SafeAnchor, { href: deviceActivationURL }, deviceActivationURL),
3419
3403
  ' ',
3420
3404
  "to allow access to GitHub Copilot from this app. Activation could take up to a minute after you enter the code."))),
3421
3405
  ghLoginStatus === GitHubCopilotLoginStatus.ActivatingDevice && (React.createElement("div", { style: { marginTop: '10px' } },
@@ -0,0 +1,33 @@
1
+ import React from 'react';
2
+ import { JupyterFrontEnd } from '@jupyterlab/application';
3
+ type MarkdownLinkProps = {
4
+ app: JupyterFrontEnd;
5
+ baseDir: string;
6
+ href: unknown;
7
+ title?: unknown;
8
+ children?: React.ReactNode;
9
+ };
10
+ /**
11
+ * Render an anchor node coming out of `react-markdown` so chat-sidebar
12
+ * links can never replace the JupyterLab shell or pivot through the
13
+ * lab origin.
14
+ *
15
+ * Three branches:
16
+ * - Fragment-only (`#section`): inert plain text. A new-tab open would
17
+ * navigate to `about:blank#section`, and a same-tab open would scroll
18
+ * the wrong document; neither matches what the LLM meant.
19
+ * - Workspace-relative (no scheme, no leading `/`, no `//` prefix):
20
+ * resolved against the active document's directory, re-validated,
21
+ * and routed through JupyterLab's `docmanager:open` command so a
22
+ * `.ipynb` opens with the notebook factory and a `.md` opens in the
23
+ * editor. The anchor's `href` stays `"#"` because a populated `href`
24
+ * bypasses React's onClick on middle/Cmd-click, letting the browser
25
+ * navigate `/lab/<path>` with session cookies attached; the hover
26
+ * preview moves to `title` so the user still sees the intended
27
+ * target.
28
+ * - Everything else: handed to `SafeAnchor`, which enforces the
29
+ * `safeAnchorUri` scheme allowlist and emits a `_blank` anchor with
30
+ * `rel="noopener noreferrer"`.
31
+ */
32
+ export declare function MarkdownLink({ app, baseDir, href, title, children }: MarkdownLinkProps): React.ReactElement;
33
+ export {};
@@ -0,0 +1,114 @@
1
+ // Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
2
+ import React from 'react';
3
+ import { PathExt } from '@jupyterlab/coreutils';
4
+ import { SafeAnchor } from './safe-anchor';
5
+ import { hasDangerousTextCodepoints } from '../utils';
6
+ // Match an absolute URI by its scheme prefix so a workspace-relative path
7
+ // (`README.md`) is distinguished from a protocol-rooted URL (`http://...`).
8
+ // Mirrors the SCHEME_RE in utils.ts; kept local because this discriminant
9
+ // answers a different question (presence vs. allowlist).
10
+ const SCHEME_PREFIX_RE = /^[A-Za-z][A-Za-z0-9+.-]*:/;
11
+ /**
12
+ * True when a freshly-joined workspace path is *not* safe to hand to
13
+ * `docmanager:open` or expose on a rendered anchor. Rejects:
14
+ *
15
+ * - leading `..` segments or absolute paths: the join didn't anchor and
16
+ * the path escapes the Jupyter root (ContentsManager rejects too, but
17
+ * we want to fail closed visually as well so the status bar/title
18
+ * never previews a traversal target),
19
+ * - any embedded scheme: `PathExt.join('', 'java\tscript:alert(1)')`
20
+ * returns the input verbatim, so a path that looks workspace-relative
21
+ * pre-join can unmask into a `javascript:` href when `baseDir` is
22
+ * empty (any active doc at server root),
23
+ * - dangerous codepoints (bidi-override, zero-width, C0/C1/DEL, etc.):
24
+ * the WHATWG URL parser strips these from the scheme during
25
+ * recognition, and they also visually impersonate the link target on
26
+ * hover / in dev-tools logs.
27
+ */
28
+ function isUnsafeWorkspacePath(path) {
29
+ // Empty / cwd-only paths reach here when react-markdown's built-in
30
+ // `urlTransform` strips an unsafe scheme (`javascript:`, `data:`, ...)
31
+ // to an empty string before our override runs: the result joins to
32
+ // either `""` or `"."`, both of which would render as a dead
33
+ // `<a href="#">` that 404s on click. Surface them as blocked-link
34
+ // spans so the user sees why nothing happened.
35
+ if (path === '' || path === '.' || path === './') {
36
+ return true;
37
+ }
38
+ if (path.startsWith('/') || path === '..' || path.startsWith('../')) {
39
+ return true;
40
+ }
41
+ if (SCHEME_PREFIX_RE.test(path)) {
42
+ return true;
43
+ }
44
+ if (hasDangerousTextCodepoints(path)) {
45
+ return true;
46
+ }
47
+ return false;
48
+ }
49
+ /**
50
+ * Render an anchor node coming out of `react-markdown` so chat-sidebar
51
+ * links can never replace the JupyterLab shell or pivot through the
52
+ * lab origin.
53
+ *
54
+ * Three branches:
55
+ * - Fragment-only (`#section`): inert plain text. A new-tab open would
56
+ * navigate to `about:blank#section`, and a same-tab open would scroll
57
+ * the wrong document; neither matches what the LLM meant.
58
+ * - Workspace-relative (no scheme, no leading `/`, no `//` prefix):
59
+ * resolved against the active document's directory, re-validated,
60
+ * and routed through JupyterLab's `docmanager:open` command so a
61
+ * `.ipynb` opens with the notebook factory and a `.md` opens in the
62
+ * editor. The anchor's `href` stays `"#"` because a populated `href`
63
+ * bypasses React's onClick on middle/Cmd-click, letting the browser
64
+ * navigate `/lab/<path>` with session cookies attached; the hover
65
+ * preview moves to `title` so the user still sees the intended
66
+ * target.
67
+ * - Everything else: handed to `SafeAnchor`, which enforces the
68
+ * `safeAnchorUri` scheme allowlist and emits a `_blank` anchor with
69
+ * `rel="noopener noreferrer"`.
70
+ */
71
+ export function MarkdownLink({ app, baseDir, href, title, children }) {
72
+ if (typeof href === 'string') {
73
+ if (href.startsWith('#')) {
74
+ return React.createElement("span", null, children);
75
+ }
76
+ if (!SCHEME_PREFIX_RE.test(href) &&
77
+ !href.startsWith('/') &&
78
+ !href.startsWith('//')) {
79
+ // PathExt.join: plain concatenation + normalization. Resolve()
80
+ // would fall back to the browser process cwd when `baseDir` is
81
+ // relative, which gives nonsense like `/Users/.../notebooks/...`.
82
+ const resolvedPath = PathExt.join(baseDir, href);
83
+ // Re-validate post-join. Two attack/confusion shapes the pre-check
84
+ // alone misses: `[x](java\tscript:alert(1))` survives the scheme
85
+ // sniff because `\t` isn't a scheme char, then unmasks once the
86
+ // WHATWG parser sees the joined href; `[x](../../../etc/passwd)`
87
+ // looks workspace-relative but escapes the workspace root.
88
+ if (isUnsafeWorkspacePath(resolvedPath)) {
89
+ return (React.createElement(SafeAnchor, { href: null, title: undefined }, children));
90
+ }
91
+ // href="#" rather than href={resolvedPath}: a modifier-click on a
92
+ // populated href bypasses the React onClick, lets the browser
93
+ // navigate the chat sidebar to /lab/<path> in a new tab, and would
94
+ // ride along the user's Jupyter session cookies. The hover preview
95
+ // moves to `title` so the user still sees the intended target.
96
+ const safeTitleFromMd = typeof title === 'string' && !hasDangerousTextCodepoints(title)
97
+ ? title
98
+ : undefined;
99
+ const hoverTitle = safeTitleFromMd !== null && safeTitleFromMd !== void 0 ? safeTitleFromMd : resolvedPath;
100
+ const onClick = (e) => {
101
+ e.preventDefault();
102
+ // ContentsManager rejects paths outside the Jupyter root with a
103
+ // promise rejection. Catch so the failure surfaces in logs instead
104
+ // of an unhandled rejection, and the user can see the rendered
105
+ // anchor was attempted even when the target doesn't exist.
106
+ Promise.resolve(app.commands.execute('docmanager:open', { path: resolvedPath })).catch(err => {
107
+ console.warn(`NBI: failed to open workspace path "${resolvedPath}":`, err);
108
+ });
109
+ };
110
+ return (React.createElement("a", { href: "#", title: hoverTitle, onClick: onClick }, children));
111
+ }
112
+ }
113
+ return (React.createElement(SafeAnchor, { href: typeof href === 'string' ? href : null, title: typeof title === 'string' ? title : undefined }, children));
114
+ }
@@ -0,0 +1,25 @@
1
+ import React from 'react';
2
+ type SafeAnchorProps = {
3
+ href: string | undefined | null;
4
+ children: React.ReactNode;
5
+ title?: string;
6
+ className?: string;
7
+ };
8
+ /**
9
+ * The single render path for anchor elements driven by LLM / tool output.
10
+ *
11
+ * Runs `href` through `safeAnchorUri`, which mirrors the server-side
12
+ * `safe_anchor_uri` allowlist (`http` / `https` / `mailto`) and rejects
13
+ * dangerous codepoints. On accept it renders a `_blank` anchor with
14
+ * `rel="noopener noreferrer"` and an SR-only "(opens in new tab)" suffix;
15
+ * on reject it falls through to plain text plus an SR-only "(link
16
+ * blocked)" note so screen readers can tell why the link disappeared.
17
+ *
18
+ * The `title` attribute is scrubbed for the same dangerous codepoints
19
+ * the URI check rejects, since react-markdown forwards CommonMark
20
+ * `[text](url "title")` titles to the rendered anchor and an LLM can
21
+ * smuggle bidi-override or zero-width characters there to visually
22
+ * impersonate the link target on hover.
23
+ */
24
+ export declare function SafeAnchor({ href, children, title, className }: SafeAnchorProps): React.ReactElement;
25
+ export {};
@@ -0,0 +1,33 @@
1
+ // Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
2
+ import React from 'react';
3
+ import { hasDangerousTextCodepoints, safeAnchorUri } from '../utils';
4
+ /**
5
+ * The single render path for anchor elements driven by LLM / tool output.
6
+ *
7
+ * Runs `href` through `safeAnchorUri`, which mirrors the server-side
8
+ * `safe_anchor_uri` allowlist (`http` / `https` / `mailto`) and rejects
9
+ * dangerous codepoints. On accept it renders a `_blank` anchor with
10
+ * `rel="noopener noreferrer"` and an SR-only "(opens in new tab)" suffix;
11
+ * on reject it falls through to plain text plus an SR-only "(link
12
+ * blocked)" note so screen readers can tell why the link disappeared.
13
+ *
14
+ * The `title` attribute is scrubbed for the same dangerous codepoints
15
+ * the URI check rejects, since react-markdown forwards CommonMark
16
+ * `[text](url "title")` titles to the rendered anchor and an LLM can
17
+ * smuggle bidi-override or zero-width characters there to visually
18
+ * impersonate the link target on hover.
19
+ */
20
+ export function SafeAnchor({ href, children, title, className }) {
21
+ const safeUri = safeAnchorUri(href !== null && href !== void 0 ? href : '');
22
+ if (!safeUri) {
23
+ return (React.createElement("span", { className: className },
24
+ children,
25
+ React.createElement("span", { className: "nbi-sr-only" }, " (link blocked)")));
26
+ }
27
+ const safeTitle = typeof title === 'string' && !hasDangerousTextCodepoints(title)
28
+ ? title
29
+ : undefined;
30
+ return (React.createElement("a", { href: safeUri, target: "_blank", rel: "noopener noreferrer", title: safeTitle, className: className },
31
+ children,
32
+ React.createElement("span", { className: "nbi-sr-only" }, " (opens in new tab)")));
33
+ }
@@ -6,12 +6,34 @@ import { Prism as SyntaxHighlighterBase } from 'react-syntax-highlighter';
6
6
  const SyntaxHighlighter = SyntaxHighlighterBase;
7
7
  import { oneLight, oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
8
8
  import { VscNewFile, VscInsert, VscCopy, VscNotebook, VscAdd } from './icons';
9
+ import { PathExt } from '@jupyterlab/coreutils';
10
+ import { MarkdownLink } from './components/markdown-link';
9
11
  import { isDarkTheme, writeTextToClipboard } from './utils';
10
12
  export function MarkdownRenderer({ children: markdown, getApp, getActiveDocumentInfo }) {
11
13
  const app = getApp();
12
14
  const activeDocumentInfo = getActiveDocumentInfo();
13
15
  const isNotebook = activeDocumentInfo.filename.endsWith('.ipynb');
14
- return (React.createElement(Markdown, { remarkPlugins: [remarkGfm], components: {
16
+ // Resolve workspace-relative LLM links against the active document's
17
+ // directory so `[file](README.md)` from a chat scoped to
18
+ // `notebooks/proj/work.ipynb` lands at `notebooks/proj/README.md` (the
19
+ // user's mental model) rather than the server-root README.
20
+ const linkBaseDir = activeDocumentInfo.filePath
21
+ ? PathExt.dirname(activeDocumentInfo.filePath)
22
+ : '';
23
+ return (
24
+ // No `rehype-raw` plugin: raw HTML in chat markdown (e.g. an LLM
25
+ // emitting `<a href="javascript:...">`) renders as literal text, not
26
+ // a DOM anchor, so the only anchor sink is the CommonMark/GFM `a`
27
+ // node handled by `SafeAnchor` below. Any future change that enables
28
+ // raw HTML needs to add a rehype-sanitize pass alongside.
29
+ React.createElement(Markdown, { remarkPlugins: [remarkGfm], components: {
30
+ // CommonMark `<https://...>` autolinks, `[text](url)`, and
31
+ // reference-style links all normalize to the same `a` node.
32
+ // `MarkdownLink` routes fragment-only and workspace-relative
33
+ // hrefs through Lab's docmanager so an LLM-emitted link can't
34
+ // replace the JupyterLab shell, and hands everything else to
35
+ // SafeAnchor for the `_blank` + scheme-allowlist treatment.
36
+ a: ({ href, title, children }) => (React.createElement(MarkdownLink, { app: app, baseDir: linkBaseDir, href: href, title: title }, children)),
15
37
  code({ node, inline, className, children, getApp, ...props }) {
16
38
  const match = /language-(\w+)/.exec(className || '');
17
39
  const codeString = String(children).replace(/\n$/, '');
package/lib/utils.d.ts CHANGED
@@ -26,6 +26,15 @@ export declare function getSelectionInEditor(editor: CodeEditor.IEditor): string
26
26
  export declare function getWholeNotebookContent(np: NotebookPanel): string;
27
27
  export declare function applyCodeToSelectionInEditor(editor: CodeEditor.IEditor, code: string): void;
28
28
  export { shellSingleQuote };
29
+ /**
30
+ * True when `s` contains any codepoint in the same set `safeAnchorUri`
31
+ * rejects (C0/DEL/C1, NEL/NBSP/LS/PS/BOM, ZWSP, bidi-override controls).
32
+ * Mirrors the Python `has_dangerous_text_codepoints`. Used to scrub the
33
+ * `title` attribute on rendered anchors so an LLM-emitted hover tooltip
34
+ * can't visually impersonate the link via bidi-reorder or zero-width
35
+ * tricks.
36
+ */
37
+ export declare function hasDangerousTextCodepoints(s: string | undefined | null): boolean;
29
38
  /**
30
39
  * Return `uri` if its scheme is in the chat-anchor allowlist, else null.
31
40
  * Mirrors the server-side `safe_anchor_uri` check so that anchor parts
package/lib/utils.js CHANGED
@@ -277,6 +277,25 @@ function isDisallowedUriCodepoint(code) {
277
277
  }
278
278
  return false;
279
279
  }
280
+ /**
281
+ * True when `s` contains any codepoint in the same set `safeAnchorUri`
282
+ * rejects (C0/DEL/C1, NEL/NBSP/LS/PS/BOM, ZWSP, bidi-override controls).
283
+ * Mirrors the Python `has_dangerous_text_codepoints`. Used to scrub the
284
+ * `title` attribute on rendered anchors so an LLM-emitted hover tooltip
285
+ * can't visually impersonate the link via bidi-reorder or zero-width
286
+ * tricks.
287
+ */
288
+ export function hasDangerousTextCodepoints(s) {
289
+ if (typeof s !== 'string') {
290
+ return false;
291
+ }
292
+ for (let i = 0; i < s.length; i++) {
293
+ if (isDisallowedUriCodepoint(s.charCodeAt(i))) {
294
+ return true;
295
+ }
296
+ }
297
+ return false;
298
+ }
280
299
  /**
281
300
  * Return `uri` if its scheme is in the chat-anchor allowlist, else null.
282
301
  * Mirrors the server-side `safe_anchor_uri` check so that anchor parts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plmbr/notebook-intelligence",
3
- "version": "5.0.0",
3
+ "version": "5.0.1",
4
4
  "description": "AI coding assistant for JupyterLab",
5
5
  "keywords": [
6
6
  "AI",
@@ -72,10 +72,10 @@ import type { Contents } from '@jupyterlab/services';
72
72
  import {
73
73
  extractLLMGeneratedCode,
74
74
  isDarkTheme,
75
- safeAnchorUri,
76
75
  writeTextToClipboard
77
76
  } from './utils';
78
77
  import { CheckBoxItem } from './components/checkbox';
78
+ import { SafeAnchor } from './components/safe-anchor';
79
79
  import { mcpServerSettingsToEnabledState } from './components/mcp-util';
80
80
  import claudeSvgStr from '../style/icons/claude.svg';
81
81
  import { AskUserQuestion } from './components/ask-user-question';
@@ -836,23 +836,11 @@ function ChatResponse(props: any) {
836
836
  </div>
837
837
  );
838
838
  case ResponseStreamDataType.Anchor: {
839
- const safeUri = safeAnchorUri(item.content.uri);
840
- if (!safeUri) {
841
- return (
842
- <div className="chat-response-anchor" key={`key-${index}`}>
843
- <span>
844
- {item.content.title}
845
- <span className="nbi-sr-only"> (link blocked)</span>
846
- </span>
847
- </div>
848
- );
849
- }
850
839
  return (
851
840
  <div className="chat-response-anchor" key={`key-${index}`}>
852
- <a href={safeUri} target="_blank" rel="noopener noreferrer">
841
+ <SafeAnchor href={item.content.uri}>
853
842
  {item.content.title}
854
- <span className="nbi-sr-only"> (opens in new tab)</span>
855
- </a>
843
+ </SafeAnchor>
856
844
  </div>
857
845
  );
858
846
  }
@@ -4956,48 +4944,28 @@ function GitHubCopilotLoginDialogBodyComponent(props: any) {
4956
4944
  memory.
4957
4945
  </div>
4958
4946
  <div>
4959
- <a
4960
- href="https://github.com/features/copilot"
4961
- target="_blank"
4962
- rel="noopener noreferrer"
4963
- >
4947
+ <SafeAnchor href="https://github.com/features/copilot">
4964
4948
  GitHub Copilot
4965
- <span className="nbi-sr-only"> (opens in new tab)</span>
4966
- </a>{' '}
4949
+ </SafeAnchor>{' '}
4967
4950
  requires a subscription and it has a free tier. GitHub Copilot is
4968
4951
  subject to the{' '}
4969
- <a
4970
- href="https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features"
4971
- target="_blank"
4972
- rel="noopener noreferrer"
4973
- >
4952
+ <SafeAnchor href="https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features">
4974
4953
  GitHub Terms for Additional Products and Features
4975
- <span className="nbi-sr-only"> (opens in new tab)</span>
4976
- </a>
4954
+ </SafeAnchor>
4977
4955
  .
4978
4956
  </div>
4979
4957
  <div>
4980
4958
  <h4>Privacy and terms</h4>
4981
4959
  By using Notebook Intelligence with GitHub Copilot subscription you
4982
4960
  agree to{' '}
4983
- <a
4984
- href="https://docs.github.com/en/copilot/responsible-use-of-github-copilot-features/responsible-use-of-github-copilot-chat-in-your-ide"
4985
- target="_blank"
4986
- rel="noopener noreferrer"
4987
- >
4961
+ <SafeAnchor href="https://docs.github.com/en/copilot/responsible-use-of-github-copilot-features/responsible-use-of-github-copilot-chat-in-your-ide">
4988
4962
  GitHub Copilot chat terms
4989
- <span className="nbi-sr-only"> (opens in new tab)</span>
4990
- </a>
4963
+ </SafeAnchor>
4991
4964
  . Review the terms to understand about usage, limitations and ways
4992
4965
  to improve GitHub Copilot. Please review{' '}
4993
- <a
4994
- href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement"
4995
- target="_blank"
4996
- rel="noopener noreferrer"
4997
- >
4966
+ <SafeAnchor href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement">
4998
4967
  Privacy Statement
4999
- <span className="nbi-sr-only"> (opens in new tab)</span>
5000
- </a>
4968
+ </SafeAnchor>
5001
4969
  .
5002
4970
  </div>
5003
4971
  <div>
@@ -5046,13 +5014,9 @@ function GitHubCopilotLoginDialogBodyComponent(props: any) {
5046
5014
  </b>
5047
5015
  </span>{' '}
5048
5016
  and enter at{' '}
5049
- <a
5050
- href={deviceActivationURL}
5051
- target="_blank"
5052
- rel="noopener noreferrer"
5053
- >
5017
+ <SafeAnchor href={deviceActivationURL}>
5054
5018
  {deviceActivationURL}
5055
- </a>{' '}
5019
+ </SafeAnchor>{' '}
5056
5020
  to allow access to GitHub Copilot from this app. Activation could
5057
5021
  take up to a minute after you enter the code.
5058
5022
  </div>
@@ -0,0 +1,161 @@
1
+ // Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
2
+
3
+ import React from 'react';
4
+ import { JupyterFrontEnd } from '@jupyterlab/application';
5
+ import { PathExt } from '@jupyterlab/coreutils';
6
+ import { SafeAnchor } from './safe-anchor';
7
+ import { hasDangerousTextCodepoints } from '../utils';
8
+
9
+ // Match an absolute URI by its scheme prefix so a workspace-relative path
10
+ // (`README.md`) is distinguished from a protocol-rooted URL (`http://...`).
11
+ // Mirrors the SCHEME_RE in utils.ts; kept local because this discriminant
12
+ // answers a different question (presence vs. allowlist).
13
+ const SCHEME_PREFIX_RE = /^[A-Za-z][A-Za-z0-9+.-]*:/;
14
+
15
+ /**
16
+ * True when a freshly-joined workspace path is *not* safe to hand to
17
+ * `docmanager:open` or expose on a rendered anchor. Rejects:
18
+ *
19
+ * - leading `..` segments or absolute paths: the join didn't anchor and
20
+ * the path escapes the Jupyter root (ContentsManager rejects too, but
21
+ * we want to fail closed visually as well so the status bar/title
22
+ * never previews a traversal target),
23
+ * - any embedded scheme: `PathExt.join('', 'java\tscript:alert(1)')`
24
+ * returns the input verbatim, so a path that looks workspace-relative
25
+ * pre-join can unmask into a `javascript:` href when `baseDir` is
26
+ * empty (any active doc at server root),
27
+ * - dangerous codepoints (bidi-override, zero-width, C0/C1/DEL, etc.):
28
+ * the WHATWG URL parser strips these from the scheme during
29
+ * recognition, and they also visually impersonate the link target on
30
+ * hover / in dev-tools logs.
31
+ */
32
+ function isUnsafeWorkspacePath(path: string): boolean {
33
+ // Empty / cwd-only paths reach here when react-markdown's built-in
34
+ // `urlTransform` strips an unsafe scheme (`javascript:`, `data:`, ...)
35
+ // to an empty string before our override runs: the result joins to
36
+ // either `""` or `"."`, both of which would render as a dead
37
+ // `<a href="#">` that 404s on click. Surface them as blocked-link
38
+ // spans so the user sees why nothing happened.
39
+ if (path === '' || path === '.' || path === './') {
40
+ return true;
41
+ }
42
+ if (path.startsWith('/') || path === '..' || path.startsWith('../')) {
43
+ return true;
44
+ }
45
+ if (SCHEME_PREFIX_RE.test(path)) {
46
+ return true;
47
+ }
48
+ if (hasDangerousTextCodepoints(path)) {
49
+ return true;
50
+ }
51
+ return false;
52
+ }
53
+
54
+ type MarkdownLinkProps = {
55
+ app: JupyterFrontEnd;
56
+ // Directory the LLM-emitted relative link should resolve against. The
57
+ // active document's directory matches the user's mental model: a
58
+ // workspace-relative link like `[file](README.md)` in a chat scoped to
59
+ // `notebooks/proj/work.ipynb` lands at `notebooks/proj/README.md`, not
60
+ // at the server-root README. Empty string is treated as "server root".
61
+ baseDir: string;
62
+ href: unknown;
63
+ title?: unknown;
64
+ children?: React.ReactNode;
65
+ };
66
+
67
+ /**
68
+ * Render an anchor node coming out of `react-markdown` so chat-sidebar
69
+ * links can never replace the JupyterLab shell or pivot through the
70
+ * lab origin.
71
+ *
72
+ * Three branches:
73
+ * - Fragment-only (`#section`): inert plain text. A new-tab open would
74
+ * navigate to `about:blank#section`, and a same-tab open would scroll
75
+ * the wrong document; neither matches what the LLM meant.
76
+ * - Workspace-relative (no scheme, no leading `/`, no `//` prefix):
77
+ * resolved against the active document's directory, re-validated,
78
+ * and routed through JupyterLab's `docmanager:open` command so a
79
+ * `.ipynb` opens with the notebook factory and a `.md` opens in the
80
+ * editor. The anchor's `href` stays `"#"` because a populated `href`
81
+ * bypasses React's onClick on middle/Cmd-click, letting the browser
82
+ * navigate `/lab/<path>` with session cookies attached; the hover
83
+ * preview moves to `title` so the user still sees the intended
84
+ * target.
85
+ * - Everything else: handed to `SafeAnchor`, which enforces the
86
+ * `safeAnchorUri` scheme allowlist and emits a `_blank` anchor with
87
+ * `rel="noopener noreferrer"`.
88
+ */
89
+ export function MarkdownLink({
90
+ app,
91
+ baseDir,
92
+ href,
93
+ title,
94
+ children
95
+ }: MarkdownLinkProps): React.ReactElement {
96
+ if (typeof href === 'string') {
97
+ if (href.startsWith('#')) {
98
+ return <span>{children}</span>;
99
+ }
100
+ if (
101
+ !SCHEME_PREFIX_RE.test(href) &&
102
+ !href.startsWith('/') &&
103
+ !href.startsWith('//')
104
+ ) {
105
+ // PathExt.join: plain concatenation + normalization. Resolve()
106
+ // would fall back to the browser process cwd when `baseDir` is
107
+ // relative, which gives nonsense like `/Users/.../notebooks/...`.
108
+ const resolvedPath = PathExt.join(baseDir, href);
109
+ // Re-validate post-join. Two attack/confusion shapes the pre-check
110
+ // alone misses: `[x](java\tscript:alert(1))` survives the scheme
111
+ // sniff because `\t` isn't a scheme char, then unmasks once the
112
+ // WHATWG parser sees the joined href; `[x](../../../etc/passwd)`
113
+ // looks workspace-relative but escapes the workspace root.
114
+ if (isUnsafeWorkspacePath(resolvedPath)) {
115
+ return (
116
+ <SafeAnchor href={null} title={undefined}>
117
+ {children}
118
+ </SafeAnchor>
119
+ );
120
+ }
121
+ // href="#" rather than href={resolvedPath}: a modifier-click on a
122
+ // populated href bypasses the React onClick, lets the browser
123
+ // navigate the chat sidebar to /lab/<path> in a new tab, and would
124
+ // ride along the user's Jupyter session cookies. The hover preview
125
+ // moves to `title` so the user still sees the intended target.
126
+ const safeTitleFromMd =
127
+ typeof title === 'string' && !hasDangerousTextCodepoints(title)
128
+ ? title
129
+ : undefined;
130
+ const hoverTitle = safeTitleFromMd ?? resolvedPath;
131
+ const onClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
132
+ e.preventDefault();
133
+ // ContentsManager rejects paths outside the Jupyter root with a
134
+ // promise rejection. Catch so the failure surfaces in logs instead
135
+ // of an unhandled rejection, and the user can see the rendered
136
+ // anchor was attempted even when the target doesn't exist.
137
+ Promise.resolve(
138
+ app.commands.execute('docmanager:open', { path: resolvedPath })
139
+ ).catch(err => {
140
+ console.warn(
141
+ `NBI: failed to open workspace path "${resolvedPath}":`,
142
+ err
143
+ );
144
+ });
145
+ };
146
+ return (
147
+ <a href="#" title={hoverTitle} onClick={onClick}>
148
+ {children}
149
+ </a>
150
+ );
151
+ }
152
+ }
153
+ return (
154
+ <SafeAnchor
155
+ href={typeof href === 'string' ? href : null}
156
+ title={typeof title === 'string' ? title : undefined}
157
+ >
158
+ {children}
159
+ </SafeAnchor>
160
+ );
161
+ }
@@ -0,0 +1,60 @@
1
+ // Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
2
+
3
+ import React from 'react';
4
+ import { hasDangerousTextCodepoints, safeAnchorUri } from '../utils';
5
+
6
+ type SafeAnchorProps = {
7
+ href: string | undefined | null;
8
+ children: React.ReactNode;
9
+ title?: string;
10
+ className?: string;
11
+ };
12
+
13
+ /**
14
+ * The single render path for anchor elements driven by LLM / tool output.
15
+ *
16
+ * Runs `href` through `safeAnchorUri`, which mirrors the server-side
17
+ * `safe_anchor_uri` allowlist (`http` / `https` / `mailto`) and rejects
18
+ * dangerous codepoints. On accept it renders a `_blank` anchor with
19
+ * `rel="noopener noreferrer"` and an SR-only "(opens in new tab)" suffix;
20
+ * on reject it falls through to plain text plus an SR-only "(link
21
+ * blocked)" note so screen readers can tell why the link disappeared.
22
+ *
23
+ * The `title` attribute is scrubbed for the same dangerous codepoints
24
+ * the URI check rejects, since react-markdown forwards CommonMark
25
+ * `[text](url "title")` titles to the rendered anchor and an LLM can
26
+ * smuggle bidi-override or zero-width characters there to visually
27
+ * impersonate the link target on hover.
28
+ */
29
+ export function SafeAnchor({
30
+ href,
31
+ children,
32
+ title,
33
+ className
34
+ }: SafeAnchorProps): React.ReactElement {
35
+ const safeUri = safeAnchorUri(href ?? '');
36
+ if (!safeUri) {
37
+ return (
38
+ <span className={className}>
39
+ {children}
40
+ <span className="nbi-sr-only"> (link blocked)</span>
41
+ </span>
42
+ );
43
+ }
44
+ const safeTitle =
45
+ typeof title === 'string' && !hasDangerousTextCodepoints(title)
46
+ ? title
47
+ : undefined;
48
+ return (
49
+ <a
50
+ href={safeUri}
51
+ target="_blank"
52
+ rel="noopener noreferrer"
53
+ title={safeTitle}
54
+ className={className}
55
+ >
56
+ {children}
57
+ <span className="nbi-sr-only"> (opens in new tab)</span>
58
+ </a>
59
+ );
60
+ }
@@ -12,6 +12,8 @@ import {
12
12
  } from 'react-syntax-highlighter/dist/cjs/styles/prism';
13
13
  import { VscNewFile, VscInsert, VscCopy, VscNotebook, VscAdd } from './icons';
14
14
  import { JupyterFrontEnd } from '@jupyterlab/application';
15
+ import { PathExt } from '@jupyterlab/coreutils';
16
+ import { MarkdownLink } from './components/markdown-link';
15
17
  import { isDarkTheme, writeTextToClipboard } from './utils';
16
18
  import { IActiveDocumentInfo } from './tokens';
17
19
 
@@ -29,11 +31,39 @@ export function MarkdownRenderer({
29
31
  const app = getApp();
30
32
  const activeDocumentInfo = getActiveDocumentInfo();
31
33
  const isNotebook = activeDocumentInfo.filename.endsWith('.ipynb');
34
+ // Resolve workspace-relative LLM links against the active document's
35
+ // directory so `[file](README.md)` from a chat scoped to
36
+ // `notebooks/proj/work.ipynb` lands at `notebooks/proj/README.md` (the
37
+ // user's mental model) rather than the server-root README.
38
+ const linkBaseDir = activeDocumentInfo.filePath
39
+ ? PathExt.dirname(activeDocumentInfo.filePath)
40
+ : '';
32
41
 
33
42
  return (
43
+ // No `rehype-raw` plugin: raw HTML in chat markdown (e.g. an LLM
44
+ // emitting `<a href="javascript:...">`) renders as literal text, not
45
+ // a DOM anchor, so the only anchor sink is the CommonMark/GFM `a`
46
+ // node handled by `SafeAnchor` below. Any future change that enables
47
+ // raw HTML needs to add a rehype-sanitize pass alongside.
34
48
  <Markdown
35
49
  remarkPlugins={[remarkGfm]}
36
50
  components={{
51
+ // CommonMark `<https://...>` autolinks, `[text](url)`, and
52
+ // reference-style links all normalize to the same `a` node.
53
+ // `MarkdownLink` routes fragment-only and workspace-relative
54
+ // hrefs through Lab's docmanager so an LLM-emitted link can't
55
+ // replace the JupyterLab shell, and hands everything else to
56
+ // SafeAnchor for the `_blank` + scheme-allowlist treatment.
57
+ a: ({ href, title, children }: any) => (
58
+ <MarkdownLink
59
+ app={app}
60
+ baseDir={linkBaseDir}
61
+ href={href}
62
+ title={title}
63
+ >
64
+ {children}
65
+ </MarkdownLink>
66
+ ),
37
67
  code({ node, inline, className, children, getApp, ...props }: any) {
38
68
  const match = /language-(\w+)/.exec(className || '');
39
69
  const codeString = String(children).replace(/\n$/, '');
package/src/utils.ts CHANGED
@@ -339,6 +339,28 @@ function isDisallowedUriCodepoint(code: number): boolean {
339
339
  return false;
340
340
  }
341
341
 
342
+ /**
343
+ * True when `s` contains any codepoint in the same set `safeAnchorUri`
344
+ * rejects (C0/DEL/C1, NEL/NBSP/LS/PS/BOM, ZWSP, bidi-override controls).
345
+ * Mirrors the Python `has_dangerous_text_codepoints`. Used to scrub the
346
+ * `title` attribute on rendered anchors so an LLM-emitted hover tooltip
347
+ * can't visually impersonate the link via bidi-reorder or zero-width
348
+ * tricks.
349
+ */
350
+ export function hasDangerousTextCodepoints(
351
+ s: string | undefined | null
352
+ ): boolean {
353
+ if (typeof s !== 'string') {
354
+ return false;
355
+ }
356
+ for (let i = 0; i < s.length; i++) {
357
+ if (isDisallowedUriCodepoint(s.charCodeAt(i))) {
358
+ return true;
359
+ }
360
+ }
361
+ return false;
362
+ }
363
+
342
364
  /**
343
365
  * Return `uri` if its scheme is in the chat-anchor allowlist, else null.
344
366
  * Mirrors the server-side `safe_anchor_uri` check so that anchor parts