Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 13 additions & 49 deletions src/chat-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,10 @@ import type { Contents } from '@jupyterlab/services';
import {
extractLLMGeneratedCode,
isDarkTheme,
safeAnchorUri,
writeTextToClipboard
} from './utils';
import { CheckBoxItem } from './components/checkbox';
import { SafeAnchor } from './components/safe-anchor';
import { mcpServerSettingsToEnabledState } from './components/mcp-util';
import claudeSvgStr from '../style/icons/claude.svg';
import { AskUserQuestion } from './components/ask-user-question';
Expand Down Expand Up @@ -836,23 +836,11 @@ function ChatResponse(props: any) {
</div>
);
case ResponseStreamDataType.Anchor: {
const safeUri = safeAnchorUri(item.content.uri);
if (!safeUri) {
return (
<div className="chat-response-anchor" key={`key-${index}`}>
<span>
{item.content.title}
<span className="nbi-sr-only"> (link blocked)</span>
</span>
</div>
);
}
return (
<div className="chat-response-anchor" key={`key-${index}`}>
<a href={safeUri} target="_blank" rel="noopener noreferrer">
<SafeAnchor href={item.content.uri}>
{item.content.title}
<span className="nbi-sr-only"> (opens in new tab)</span>
</a>
</SafeAnchor>
</div>
);
}
Expand Down Expand Up @@ -4956,48 +4944,28 @@ function GitHubCopilotLoginDialogBodyComponent(props: any) {
memory.
</div>
<div>
<a
href="https://github.com/features/copilot"
target="_blank"
rel="noopener noreferrer"
>
<SafeAnchor href="https://github.com/features/copilot">
GitHub Copilot
<span className="nbi-sr-only"> (opens in new tab)</span>
</a>{' '}
</SafeAnchor>{' '}
requires a subscription and it has a free tier. GitHub Copilot is
subject to the{' '}
<a
href="https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features"
target="_blank"
rel="noopener noreferrer"
>
<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
<span className="nbi-sr-only"> (opens in new tab)</span>
</a>
</SafeAnchor>
.
</div>
<div>
<h4>Privacy and terms</h4>
By using Notebook Intelligence with GitHub Copilot subscription you
agree to{' '}
<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"
>
<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
<span className="nbi-sr-only"> (opens in new tab)</span>
</a>
</SafeAnchor>
. Review the terms to understand about usage, limitations and ways
to improve GitHub Copilot. Please review{' '}
<a
href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement"
target="_blank"
rel="noopener noreferrer"
>
<SafeAnchor href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement">
Privacy Statement
<span className="nbi-sr-only"> (opens in new tab)</span>
</a>
</SafeAnchor>
.
</div>
<div>
Expand Down Expand Up @@ -5046,13 +5014,9 @@ function GitHubCopilotLoginDialogBodyComponent(props: any) {
</b>
</span>{' '}
and enter at{' '}
<a
href={deviceActivationURL}
target="_blank"
rel="noopener noreferrer"
>
<SafeAnchor href={deviceActivationURL}>
{deviceActivationURL}
</a>{' '}
</SafeAnchor>{' '}
to allow access to GitHub Copilot from this app. Activation could
take up to a minute after you enter the code.
</div>
Expand Down
161 changes: 161 additions & 0 deletions src/components/markdown-link.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
// Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>

import React from 'react';
import { JupyterFrontEnd } from '@jupyterlab/application';
import { PathExt } from '@jupyterlab/coreutils';
import { SafeAnchor } from './safe-anchor';
import { hasDangerousTextCodepoints } from '../utils';

// Match an absolute URI by its scheme prefix so a workspace-relative path
// (`README.md`) is distinguished from a protocol-rooted URL (`http://...`).
// Mirrors the SCHEME_RE in utils.ts; kept local because this discriminant
// answers a different question (presence vs. allowlist).
const SCHEME_PREFIX_RE = /^[A-Za-z][A-Za-z0-9+.-]*:/;

/**
* True when a freshly-joined workspace path is *not* safe to hand to
* `docmanager:open` or expose on a rendered anchor. Rejects:
*
* - leading `..` segments or absolute paths: the join didn't anchor and
* the path escapes the Jupyter root (ContentsManager rejects too, but
* we want to fail closed visually as well so the status bar/title
* never previews a traversal target),
* - any embedded scheme: `PathExt.join('', 'java\tscript:alert(1)')`
* returns the input verbatim, so a path that looks workspace-relative
* pre-join can unmask into a `javascript:` href when `baseDir` is
* empty (any active doc at server root),
* - dangerous codepoints (bidi-override, zero-width, C0/C1/DEL, etc.):
* the WHATWG URL parser strips these from the scheme during
* recognition, and they also visually impersonate the link target on
* hover / in dev-tools logs.
*/
function isUnsafeWorkspacePath(path: string): boolean {
// Empty / cwd-only paths reach here when react-markdown's built-in
// `urlTransform` strips an unsafe scheme (`javascript:`, `data:`, ...)
// to an empty string before our override runs: the result joins to
// either `""` or `"."`, both of which would render as a dead
// `<a href="#">` that 404s on click. Surface them as blocked-link
// spans so the user sees why nothing happened.
if (path === '' || path === '.' || path === './') {
return true;
}
if (path.startsWith('/') || path === '..' || path.startsWith('../')) {
return true;
}
if (SCHEME_PREFIX_RE.test(path)) {
return true;
}
if (hasDangerousTextCodepoints(path)) {
return true;
}
return false;
}

type MarkdownLinkProps = {
app: JupyterFrontEnd;
// Directory the LLM-emitted relative link should resolve against. The
// active document's directory matches the user's mental model: a
// workspace-relative link like `[file](README.md)` in a chat scoped to
// `notebooks/proj/work.ipynb` lands at `notebooks/proj/README.md`, not
// at the server-root README. Empty string is treated as "server root".
baseDir: string;
href: unknown;
title?: unknown;
children?: React.ReactNode;
};

/**
* Render an anchor node coming out of `react-markdown` so chat-sidebar
* links can never replace the JupyterLab shell or pivot through the
* lab origin.
*
* Three branches:
* - Fragment-only (`#section`): inert plain text. A new-tab open would
* navigate to `about:blank#section`, and a same-tab open would scroll
* the wrong document; neither matches what the LLM meant.
* - Workspace-relative (no scheme, no leading `/`, no `//` prefix):
* resolved against the active document's directory, re-validated,
* and routed through JupyterLab's `docmanager:open` command so a
* `.ipynb` opens with the notebook factory and a `.md` opens in the
* editor. The anchor's `href` stays `"#"` because a populated `href`
* bypasses React's onClick on middle/Cmd-click, letting the browser
* navigate `/lab/<path>` with session cookies attached; the hover
* preview moves to `title` so the user still sees the intended
* target.
* - Everything else: handed to `SafeAnchor`, which enforces the
* `safeAnchorUri` scheme allowlist and emits a `_blank` anchor with
* `rel="noopener noreferrer"`.
*/
export function MarkdownLink({
app,
baseDir,
href,
title,
children
}: MarkdownLinkProps): React.ReactElement {
if (typeof href === 'string') {
if (href.startsWith('#')) {
return <span>{children}</span>;
}
if (
!SCHEME_PREFIX_RE.test(href) &&
!href.startsWith('/') &&
!href.startsWith('//')
) {
// PathExt.join: plain concatenation + normalization. Resolve()
// would fall back to the browser process cwd when `baseDir` is
// relative, which gives nonsense like `/Users/.../notebooks/...`.
const resolvedPath = PathExt.join(baseDir, href);
// Re-validate post-join. Two attack/confusion shapes the pre-check
// alone misses: `[x](java\tscript:alert(1))` survives the scheme
// sniff because `\t` isn't a scheme char, then unmasks once the
// WHATWG parser sees the joined href; `[x](../../../etc/passwd)`
// looks workspace-relative but escapes the workspace root.
if (isUnsafeWorkspacePath(resolvedPath)) {
return (
<SafeAnchor href={null} title={undefined}>
{children}
</SafeAnchor>
);
}
// href="#" rather than href={resolvedPath}: a modifier-click on a
// populated href bypasses the React onClick, lets the browser
// navigate the chat sidebar to /lab/<path> in a new tab, and would
// ride along the user's Jupyter session cookies. The hover preview
// moves to `title` so the user still sees the intended target.
const safeTitleFromMd =
typeof title === 'string' && !hasDangerousTextCodepoints(title)
? title
: undefined;
const hoverTitle = safeTitleFromMd ?? resolvedPath;
const onClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
// ContentsManager rejects paths outside the Jupyter root with a
// promise rejection. Catch so the failure surfaces in logs instead
// of an unhandled rejection, and the user can see the rendered
// anchor was attempted even when the target doesn't exist.
Promise.resolve(
app.commands.execute('docmanager:open', { path: resolvedPath })
).catch(err => {
console.warn(
`NBI: failed to open workspace path "${resolvedPath}":`,
err
);
});
};
return (
<a href="#" title={hoverTitle} onClick={onClick}>
{children}
</a>
);
}
}
return (
<SafeAnchor
href={typeof href === 'string' ? href : null}
title={typeof title === 'string' ? title : undefined}
>
{children}
</SafeAnchor>
);
}
60 changes: 60 additions & 0 deletions src/components/safe-anchor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>

import React from 'react';
import { hasDangerousTextCodepoints, safeAnchorUri } from '../utils';

type SafeAnchorProps = {
href: string | undefined | null;
children: React.ReactNode;
title?: string;
className?: string;
};

/**
* The single render path for anchor elements driven by LLM / tool output.
*
* Runs `href` through `safeAnchorUri`, which mirrors the server-side
* `safe_anchor_uri` allowlist (`http` / `https` / `mailto`) and rejects
* dangerous codepoints. On accept it renders a `_blank` anchor with
* `rel="noopener noreferrer"` and an SR-only "(opens in new tab)" suffix;
* on reject it falls through to plain text plus an SR-only "(link
* blocked)" note so screen readers can tell why the link disappeared.
*
* The `title` attribute is scrubbed for the same dangerous codepoints
* the URI check rejects, since react-markdown forwards CommonMark
* `[text](url "title")` titles to the rendered anchor and an LLM can
* smuggle bidi-override or zero-width characters there to visually
* impersonate the link target on hover.
*/
export function SafeAnchor({
href,
children,
title,
className
}: SafeAnchorProps): React.ReactElement {
const safeUri = safeAnchorUri(href ?? '');
if (!safeUri) {
return (
<span className={className}>
{children}
<span className="nbi-sr-only"> (link blocked)</span>
</span>
);
}
const safeTitle =
typeof title === 'string' && !hasDangerousTextCodepoints(title)
? title
: undefined;
return (
<a
href={safeUri}
target="_blank"
rel="noopener noreferrer"
title={safeTitle}
className={className}
>
{children}
<span className="nbi-sr-only"> (opens in new tab)</span>
</a>
);
}
30 changes: 30 additions & 0 deletions src/markdown-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
} from 'react-syntax-highlighter/dist/cjs/styles/prism';
import { VscNewFile, VscInsert, VscCopy, VscNotebook, VscAdd } from './icons';
import { JupyterFrontEnd } from '@jupyterlab/application';
import { PathExt } from '@jupyterlab/coreutils';
import { MarkdownLink } from './components/markdown-link';
import { isDarkTheme, writeTextToClipboard } from './utils';
import { IActiveDocumentInfo } from './tokens';

Expand All @@ -29,11 +31,39 @@ export function MarkdownRenderer({
const app = getApp();
const activeDocumentInfo = getActiveDocumentInfo();
const isNotebook = activeDocumentInfo.filename.endsWith('.ipynb');
// Resolve workspace-relative LLM links against the active document's
// directory so `[file](README.md)` from a chat scoped to
// `notebooks/proj/work.ipynb` lands at `notebooks/proj/README.md` (the
// user's mental model) rather than the server-root README.
const linkBaseDir = activeDocumentInfo.filePath
? PathExt.dirname(activeDocumentInfo.filePath)
: '';

return (
// No `rehype-raw` plugin: raw HTML in chat markdown (e.g. an LLM
// emitting `<a href="javascript:...">`) renders as literal text, not
// a DOM anchor, so the only anchor sink is the CommonMark/GFM `a`
// node handled by `SafeAnchor` below. Any future change that enables
// raw HTML needs to add a rehype-sanitize pass alongside.
<Markdown
remarkPlugins={[remarkGfm]}
components={{
// CommonMark `<https://...>` autolinks, `[text](url)`, and
// reference-style links all normalize to the same `a` node.
// `MarkdownLink` routes fragment-only and workspace-relative
// hrefs through Lab's docmanager so an LLM-emitted link can't
// replace the JupyterLab shell, and hands everything else to
// SafeAnchor for the `_blank` + scheme-allowlist treatment.
a: ({ href, title, children }: any) => (
<MarkdownLink
app={app}
baseDir={linkBaseDir}
href={href}
title={title}
>
{children}
</MarkdownLink>
),
code({ node, inline, className, children, getApp, ...props }: any) {
const match = /language-(\w+)/.exec(className || '');
const codeString = String(children).replace(/\n$/, '');
Expand Down
Loading
Loading