@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 +5 -5
- package/lib/chat-sidebar.js +8 -24
- package/lib/components/markdown-link.d.ts +33 -0
- package/lib/components/markdown-link.js +114 -0
- package/lib/components/safe-anchor.d.ts +25 -0
- package/lib/components/safe-anchor.js +33 -0
- package/lib/markdown-renderer.js +23 -1
- package/lib/utils.d.ts +9 -0
- package/lib/utils.js +19 -0
- package/package.json +1 -1
- package/src/chat-sidebar.tsx +13 -49
- package/src/components/markdown-link.tsx +161 -0
- package/src/components/safe-anchor.tsx +60 -0
- package/src/markdown-renderer.tsx +30 -0
- package/src/utils.ts +22 -0
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://
|
|
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://
|
|
402
|
-
- [Building AI Extensions for JupyterLab](https://
|
|
403
|
-
- [Building AI Agents for JupyterLab](https://
|
|
404
|
-
- [Notebook Intelligence now supports any LLM Provider and AI Model!](https://
|
|
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
|
|
package/lib/chat-sidebar.js
CHANGED
|
@@ -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,
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
+
}
|
package/lib/markdown-renderer.js
CHANGED
|
@@ -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
|
-
|
|
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
package/src/chat-sidebar.tsx
CHANGED
|
@@ -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
|
-
<
|
|
841
|
+
<SafeAnchor href={item.content.uri}>
|
|
853
842
|
{item.content.title}
|
|
854
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
4966
|
-
</a>{' '}
|
|
4949
|
+
</SafeAnchor>{' '}
|
|
4967
4950
|
requires a subscription and it has a free tier. GitHub Copilot is
|
|
4968
4951
|
subject to the{' '}
|
|
4969
|
-
<
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
<
|
|
5050
|
-
href={deviceActivationURL}
|
|
5051
|
-
target="_blank"
|
|
5052
|
-
rel="noopener noreferrer"
|
|
5053
|
-
>
|
|
5017
|
+
<SafeAnchor href={deviceActivationURL}>
|
|
5054
5018
|
{deviceActivationURL}
|
|
5055
|
-
</
|
|
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
|