Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Show detected vulnerabilities in chat codeblocks #198595

Merged
merged 3 commits into from Nov 20, 2023
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/vs/workbench/api/common/extHostTypeConverters.ts
Expand Up @@ -2302,6 +2302,11 @@ export namespace ChatResponseProgress {
checkProposedApiEnabled(extension, 'chatAgents2Additions');
return { content: MarkdownString.from(progress.markdownContent), kind: 'markdownContent' };
} else if ('content' in progress) {
if ('vulnerability' in progress && progress.vulnerability) {
checkProposedApiEnabled(extension, 'chatAgents2Additions');
return { content: progress.content, title: progress.vulnerability.title, description: progress.vulnerability!.description, kind: 'vulnerability' };
}

if (typeof progress.content === 'string') {
return { content: progress.content, kind: 'content' };
}
Expand Down
17 changes: 10 additions & 7 deletions src/vs/workbench/contrib/chat/browser/chatListRenderer.ts
Expand Up @@ -50,14 +50,14 @@ import { AccessibilityVerbositySettingId } from 'vs/workbench/contrib/accessibil
import { IAccessibleViewService } from 'vs/workbench/contrib/accessibility/browser/accessibleView';
import { ChatTreeItem, IChatCodeBlockInfo, IChatFileTreeInfo } from 'vs/workbench/contrib/chat/browser/chat';
import { ChatFollowups } from 'vs/workbench/contrib/chat/browser/chatFollowups';
import { convertParsedRequestToMarkdown, reduceInlineContentReferences, walkTreeAndAnnotateReferenceLinks } from 'vs/workbench/contrib/chat/browser/chatMarkdownDecorationsRenderer';
import { annotateSpecialMarkdownContent, convertParsedRequestToMarkdown, extractVulnerabilitiesFromText, walkTreeAndAnnotateReferenceLinks } from 'vs/workbench/contrib/chat/browser/chatMarkdownDecorationsRenderer';
import { ChatEditorOptions } from 'vs/workbench/contrib/chat/browser/chatOptions';
import { CodeBlockPart, ICodeBlockData, ICodeBlockPart } from 'vs/workbench/contrib/chat/browser/codeBlockPart';
import { IChatAgentMetadata } from 'vs/workbench/contrib/chat/common/chatAgents';
import { CONTEXT_CHAT_RESPONSE_SUPPORT_ISSUE_REPORTING, CONTEXT_REQUEST, CONTEXT_RESPONSE, CONTEXT_RESPONSE_FILTERED, CONTEXT_RESPONSE_VOTE } from 'vs/workbench/contrib/chat/common/chatContextKeys';
import { IChatProgressResponseContent } from 'vs/workbench/contrib/chat/common/chatModel';
import { IChatProgressRenderableResponseContent } from 'vs/workbench/contrib/chat/common/chatModel';
import { chatAgentLeader, chatSubcommandLeader } from 'vs/workbench/contrib/chat/common/chatParserTypes';
import { IChatContentInlineReference, IChatContentReference, IChatReplyFollowup, IChatResponseProgressFileTreeData, IChatService, InteractiveSessionVoteDirection } from 'vs/workbench/contrib/chat/common/chatService';
import { IChatContentReference, IChatReplyFollowup, IChatResponseProgressFileTreeData, IChatService, InteractiveSessionVoteDirection } from 'vs/workbench/contrib/chat/common/chatService';
import { IChatResponseMarkdownRenderData, IChatResponseRenderData, IChatResponseViewModel, IChatWelcomeMessageViewModel, isRequestVM, isResponseVM, isWelcomeVM } from 'vs/workbench/contrib/chat/common/chatViewModel';
import { IWordCountResult, getNWords } from 'vs/workbench/contrib/chat/common/chatWordCounter';
import { createFileIconThemableTreeContainerScope } from 'vs/workbench/contrib/files/browser/views/explorerView';
Expand Down Expand Up @@ -331,7 +331,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
timer.cancelAndSet(runProgressiveRender, 50, dom.getWindow(templateData.rowContainer));
runProgressiveRender(true);
} else if (isResponseVM(element)) {
const renderableResponse = reduceInlineContentReferences(element.response.value);
const renderableResponse = annotateSpecialMarkdownContent(element.response.value);
this.basicRenderElement(renderableResponse, element, index, templateData);
} else if (isRequestVM(element)) {
const markdown = 'kind' in element.message ?
Expand Down Expand Up @@ -432,7 +432,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
}
}

private basicRenderElement(value: ReadonlyArray<Exclude<IChatProgressResponseContent, IChatContentInlineReference>>, element: ChatTreeItem, index: number, templateData: IChatListItemTemplate) {
private basicRenderElement(value: ReadonlyArray<IChatProgressRenderableResponseContent>, element: ChatTreeItem, index: number, templateData: IChatListItemTemplate) {
const fillInIncompleteTokens = isResponseVM(element) && (!element.isComplete || element.isCanceled || element.errorDetails?.responseIsFiltered || element.errorDetails?.responseIsIncomplete);

dom.clearNode(templateData.value);
Expand Down Expand Up @@ -535,7 +535,8 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch

disposables.clear();

const renderableResponse = reduceInlineContentReferences(element.response.value);
const annotatedResult = annotateSpecialMarkdownContent(element.response.value);
const renderableResponse = annotatedResult;
let isFullyRendered = false;
if (element.isCanceled) {
this.traceLayout('runProgressiveRender', `canceled, index=${index}`);
Expand Down Expand Up @@ -816,8 +817,10 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
const result = this.renderer.render(markdown, {
fillInIncompleteTokens,
codeBlockRendererSync: (languageId, text) => {
const vulns = extractVulnerabilitiesFromText(text);

const hideToolbar = isResponseVM(element) && element.errorDetails?.responseIsFiltered;
const data = { languageId, text, codeBlockIndex: codeBlockIndex++, element, hideToolbar, parentContextKeyService: templateData.contextKeyService };
const data = { languageId, text: vulns.newText, codeBlockIndex: codeBlockIndex++, element, hideToolbar, parentContextKeyService: templateData.contextKeyService, vulns: vulns.vulnerabilities };
const ref = this.renderCodeBlock(data, disposables);

// Attach this after updating text/layout of the editor, so it should only be fired when the size updates later (horizontal scrollbar, wrapping)
Expand Down
Expand Up @@ -8,10 +8,11 @@ import { MarkdownString } from 'vs/base/common/htmlContent';
import { revive } from 'vs/base/common/marshalling';
import { basename } from 'vs/base/common/resources';
import { URI } from 'vs/base/common/uri';
import { IRange } from 'vs/editor/common/core/range';
import { Location } from 'vs/editor/common/languages';
import { IChatProgressResponseContent } from 'vs/workbench/contrib/chat/common/chatModel';
import { IChatProgressRenderableResponseContent, IChatProgressResponseContent } from 'vs/workbench/contrib/chat/common/chatModel';
import { ChatRequestTextPart, IParsedChatRequest } from 'vs/workbench/contrib/chat/common/chatParserTypes';
import { IChatContentInlineReference } from 'vs/workbench/contrib/chat/common/chatService';
import { IChatAgentMarkdownContentWithVulnerability, IChatContentInlineReference } from 'vs/workbench/contrib/chat/common/chatService';

const variableRefUrl = 'http://_vscodedecoration_';

Expand Down Expand Up @@ -58,10 +59,39 @@ function renderFileWidget(href: string, a: HTMLAnchorElement): void {
a.setAttribute('data-href', location.uri.with({ fragment }).toString());
}

export interface IMarkdownVulnerability {
title: string;
description: string;
range: IRange;
}

export function extractVulnerabilitiesFromText(text: string): { newText: string; vulnerabilities: IMarkdownVulnerability[] } {
const vulnerabilities: IMarkdownVulnerability[] = [];
let newText = text;
let match: RegExpExecArray | null;
while ((match = /<vscode_annotation title="(.*?)" description="(.*?)">(.*?)<\/vscode_annotation>/ms.exec(newText)) !== null) {
const [full, title, description, content] = match;
const start = match.index;
const textBefore = newText.substring(0, start);
const linesBefore = textBefore.split('\n').length - 1;
const linesInside = content.split('\n').length - 1;

const previousNewlineIdx = textBefore.lastIndexOf('\n');
const startColumn = start - (previousNewlineIdx + 1) + 1;
const endPreviousNewlineIdx = (textBefore + content).lastIndexOf('\n');
const endColumn = start + content.length - (endPreviousNewlineIdx + 1) + 1;

vulnerabilities.push({ title: decodeURIComponent(title), description: decodeURIComponent(description), range: { startLineNumber: linesBefore + 1, startColumn, endLineNumber: linesBefore + linesInside + 1, endColumn } });
newText = newText.substring(0, start) + content + newText.substring(start + full.length);
}

return { newText, vulnerabilities };
}

const contentRefUrl = 'http://_vscodecontentref_'; // must be lowercase for URI

export function reduceInlineContentReferences(response: ReadonlyArray<IChatProgressResponseContent>): ReadonlyArray<Exclude<IChatProgressResponseContent, IChatContentInlineReference>> {
const result: Exclude<IChatProgressResponseContent, IChatContentInlineReference>[] = [];
export function annotateSpecialMarkdownContent(response: ReadonlyArray<IChatProgressResponseContent>): ReadonlyArray<IChatProgressRenderableResponseContent> {
const result: Exclude<IChatProgressResponseContent, IChatContentInlineReference | IChatAgentMarkdownContentWithVulnerability>[] = [];
for (const item of response) {
const previousItem = result[result.length - 1];
if (item.kind === 'inlineReference') {
Expand All @@ -75,6 +105,13 @@ export function reduceInlineContentReferences(response: ReadonlyArray<IChatProgr
}
} else if (item.kind === 'markdownContent' && previousItem?.kind === 'markdownContent') {
result[result.length - 1] = { content: new MarkdownString(previousItem.content.value + item.content.value, { isTrusted: previousItem.content.isTrusted }), kind: 'markdownContent' };
} else if (item.kind === 'markdownVuln') {
const markdownText = `<vscode_annotation title="${encodeURIComponent(item.title)}" description="${encodeURIComponent(item.description)}">${item.content.value}</vscode_annotation>`;
if (previousItem?.kind === 'markdownContent') {
result[result.length - 1] = { content: new MarkdownString(previousItem.content.value + markdownText, { isTrusted: previousItem.content.isTrusted }), kind: 'markdownContent' };
} else {
result.push({ content: new MarkdownString(markdownText), kind: 'markdownContent' });
}
} else {
result.push(item);
}
Expand Down
58 changes: 56 additions & 2 deletions src/vs/workbench/contrib/chat/browser/codeBlockPart.css
Expand Up @@ -44,11 +44,12 @@
margin: 16px 0;
}

.interactive-result-code-block .interactive-result-editor .monaco-editor {
.interactive-result-code-block {
border: 1px solid var(--vscode-input-border, transparent);
background-color: var(--vscode-interactive-result-editor-background-color);
}

.interactive-result-code-block .interactive-result-editor .monaco-editor.focused {
.interactive-result-code-block:has(.monaco-editor.focused) {
border-color: var(--vscode-focusBorder, transparent);
}

Expand All @@ -57,3 +58,56 @@
.interactive-result-code-block .monaco-editor .overflow-guard {
border-radius: 4px;
}

.interactive-result-code-block .interactive-result-vulns {
font-size: 0.9em;
padding: 0px 8px 2px 8px;
}

.interactive-result-code-block .interactive-result-vulns-header {
display: flex;
height: 22px;
}

.interactive-result-code-block .interactive-result-vulns-header,
.interactive-result-code-block .interactive-result-vulns-list {
opacity: 0.8;
}

.interactive-result-code-block .interactive-result-vulns-list {
margin: 0px;
padding-bottom: 3px;
padding-left: 16px !important; /* Override markdown styles */
}

.interactive-result-code-block.chat-vulnerabilities-collapsed .interactive-result-vulns-list {
display: none;
}

.interactive-result-code-block .interactive-result-vulns-list .chat-vuln-title {
font-weight: bold;
}

.interactive-result-code-block.no-vulns .interactive-result-vulns {
display: none;
}

.interactive-result-code-block .interactive-result-vulns-header .monaco-button {
/* unset Button styles */
display: inline-flex;
width: 100%;
border: none;
padding: 0;
text-align: initial;
justify-content: initial;
color: var(--vscode-foreground) !important; /* This is inside .rendered-markdown */
user-select: none;
}

.interactive-result-code-block .interactive-result-vulns-header .monaco-text-button:focus {
outline: none;
}

.interactive-result-code-block .interactive-result-vulns-header .monaco-text-button:focus-visible {
outline: 1px solid var(--vscode-focusBorder);
}
65 changes: 61 additions & 4 deletions src/vs/workbench/contrib/chat/browser/codeBlockPart.ts
Expand Up @@ -9,6 +9,8 @@ import * as dom from 'vs/base/browser/dom';
import { Emitter, Event } from 'vs/base/common/event';
import { Disposable } from 'vs/base/common/lifecycle';

import { Button } from 'vs/base/browser/ui/button/button';
import { Codicon } from 'vs/base/common/codicons';
import { EditorExtensionsRegistry } from 'vs/editor/browser/editorExtensions';
import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditorWidget';
import { EDITOR_FONT_DEFAULTS, IEditorOptions } from 'vs/editor/common/config/editorOptions';
Expand All @@ -31,21 +33,23 @@ import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection';
import { AccessibilityVerbositySettingId } from 'vs/workbench/contrib/accessibility/browser/accessibilityConfiguration';
import { IMarkdownVulnerability } from 'vs/workbench/contrib/chat/browser/chatMarkdownDecorationsRenderer';
import { ChatEditorOptions } from 'vs/workbench/contrib/chat/browser/chatOptions';
import { IChatResponseViewModel, isResponseVM } from 'vs/workbench/contrib/chat/common/chatViewModel';
import { MenuPreventer } from 'vs/workbench/contrib/codeEditor/browser/menuPreventer';
import { SelectionClipboardContributionID } from 'vs/workbench/contrib/codeEditor/browser/selectionClipboard';
import { getSimpleEditorOptions } from 'vs/workbench/contrib/codeEditor/browser/simpleEditorOptions';

const $ = dom.$;


export interface ICodeBlockData {
text: string;
languageId: string;
codeBlockIndex: number;
element: unknown;
parentContextKeyService?: IContextKeyService;
hideToolbar?: boolean;
vulns?: IMarkdownVulnerability[];
}

export interface ICodeBlockActionContext {
Expand All @@ -57,7 +61,7 @@ export interface ICodeBlockActionContext {


export interface ICodeBlockPart {
readonly onDidChangeContentHeight: Event<number>;
readonly onDidChangeContentHeight: Event<void>;
readonly element: HTMLElement;
readonly textModel: ITextModel;
layout(width: number): void;
Expand All @@ -69,16 +73,20 @@ export interface ICodeBlockPart {
const defaultCodeblockPadding = 10;

export class CodeBlockPart extends Disposable implements ICodeBlockPart {
private readonly _onDidChangeContentHeight = this._register(new Emitter<number>());
private readonly _onDidChangeContentHeight = this._register(new Emitter<void>());
public readonly onDidChangeContentHeight = this._onDidChangeContentHeight.event;

private readonly editor: CodeEditorWidget;
private readonly toolbar: MenuWorkbenchToolBar;
private readonly contextKeyService: IContextKeyService;

private readonly vulnsButton: Button;
private readonly vulnsListElement: HTMLElement;

public readonly textModel: ITextModel;
public readonly element: HTMLElement;

private currentCodeBlockData: ICodeBlockData | undefined;
private currentScrollWidth = 0;

constructor(
Expand Down Expand Up @@ -134,6 +142,32 @@ export class CodeBlockPart extends Disposable implements ICodeBlockPart {
shouldForwardArgs: true
}
}));

const vulnsContainer = dom.append(this.element, $('.interactive-result-vulns'));
const vulnsHeaderElement = dom.append(vulnsContainer, $('.interactive-result-vulns-header', undefined));
this.vulnsButton = new Button(vulnsHeaderElement, {
buttonBackground: undefined,
buttonBorder: undefined,
buttonForeground: undefined,
buttonHoverBackground: undefined,
buttonSecondaryBackground: undefined,
buttonSecondaryForeground: undefined,
buttonSecondaryHoverBackground: undefined,
buttonSeparator: undefined,
supportIcons: true
});

this.vulnsListElement = dom.append(vulnsContainer, $('ul.interactive-result-vulns-list'));

this.vulnsButton.onDidClick(() => {
const element = this.currentCodeBlockData!.element as IChatResponseViewModel;
element.vulnerabilitiesListExpanded = !element.vulnerabilitiesListExpanded;
this.vulnsButton.label = this.getVulnerabilitiesLabel();
this.element.classList.toggle('chat-vulnerabilities-collapsed', !element.vulnerabilitiesListExpanded);
this._onDidChangeContentHeight.fire();
// this.updateAriaLabel(collapseButton.element, referencesLabel, element.usedReferencesExpanded);
});

this._register(this.toolbar.onDidChangeDropdownVisibility(e => {
toolbarElement.classList.toggle('force-visibility', e);
}));
Expand All @@ -155,7 +189,7 @@ export class CodeBlockPart extends Disposable implements ICodeBlockPart {
}));
this._register(this.editor.onDidContentSizeChange(e => {
if (e.contentHeightChanged) {
this._onDidChangeContentHeight.fire(e.contentHeight);
this._onDidChangeContentHeight.fire();
}
}));
this._register(this.editor.onDidBlurEditorWidget(() => {
Expand Down Expand Up @@ -220,6 +254,7 @@ export class CodeBlockPart extends Disposable implements ICodeBlockPart {


render(data: ICodeBlockData, width: number): void {
this.currentCodeBlockData = data;
if (data.parentContextKeyService) {
this.contextKeyService.updateParent(data.parentContextKeyService);
}
Expand Down Expand Up @@ -250,6 +285,28 @@ export class CodeBlockPart extends Disposable implements ICodeBlockPart {
} else {
dom.show(this.toolbar.getElement());
}

if (data.vulns?.length && isResponseVM(data.element)) {
dom.clearNode(this.vulnsListElement);
this.element.classList.remove('no-vulns');
this.element.classList.toggle('chat-vulnerabilities-collapsed', !data.element.vulnerabilitiesListExpanded);
dom.append(this.vulnsListElement, ...data.vulns.map(v => $('li', undefined, $('span.chat-vuln-title', undefined, v.title), ' ' + v.description)));
this.vulnsButton.label = this.getVulnerabilitiesLabel();
} else {
this.element.classList.add('no-vulns');
}
}

private getVulnerabilitiesLabel(): string {
if (!this.currentCodeBlockData || !this.currentCodeBlockData.vulns) {
return '';
}

const referencesLabel = this.currentCodeBlockData.vulns.length > 1 ?
localize('vulnerabilitiesPlural', "{0} vulnerabilities", this.currentCodeBlockData.vulns.length) :
localize('vulnerabilitiesSingular', "{0} vulnerability", 1);
const icon = (element: IChatResponseViewModel) => element.vulnerabilitiesListExpanded ? Codicon.chevronDown : Codicon.chevronRight;
return `${referencesLabel} $(${icon(this.currentCodeBlockData.element as IChatResponseViewModel).id})`;
}

private fixCodeText(text: string, languageId: string): string {
Expand Down