Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 2 additions & 2 deletions packages/cli/src/ui/commands/extensionsCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,8 +279,8 @@ async function exploreAction(
return {
type: 'custom_dialog' as const,
component: React.createElement(ExtensionRegistryView, {
onSelect: (extension) => {
debugLogger.debug(`Selected extension: ${extension.extensionName}`);
onSelect: async (extension) => {
await installAction(context, extension.url);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The installAction function, which is now called when an extension is selected in the gallery, contains a flawed validation logic that could lead to command injection.

In installAction (lines 478-489), the code checks for disallowed characters ([;&|'" ]) only if the input is NOT a valid URL. However, a valid URL can still contain these characters (e.g., in the pathname) and remain a valid URL according to the new URL() constructor. For example, https://example.com/repo.git;touch/tmp/pwned is a valid URL but could lead to command execution if passed to a shell command in downstream functions like cloneFromGit.

While the registry is currently a trusted source, this flaw also affects the /extensions install <source> command which takes arbitrary user input. An attacker could trick a user into installing an extension from a malicious URL, leading to remote code execution.

},
onClose: () => context.ui.removeComponent(),
extensionManager,
Expand Down
9 changes: 6 additions & 3 deletions packages/cli/src/ui/components/shared/SearchableList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ export interface SearchableListProps<T extends GenericListItem> {
onSearch?: (query: string) => void;
/** Whether to reset selection to the top when items change (e.g. after search) */
resetSelectionOnItemsChange?: boolean;
/** Whether the list is focused and accepts keyboard input. Defaults to true. */
isFocused?: boolean;
}

/**
Expand All @@ -84,6 +86,7 @@ export function SearchableList<T extends GenericListItem>({
useSearch,
onSearch,
resetSelectionOnItemsChange = false,
isFocused = true,
}: SearchableListProps<T>): React.JSX.Element {
const { filteredItems, searchBuffer, maxLabelWidth } = useSearch({
items,
Expand All @@ -109,7 +112,7 @@ export function SearchableList<T extends GenericListItem>({
const { activeIndex, setActiveIndex } = useSelectionList({
items: selectionItems,
onSelect: handleSelectValue,
isFocused: true,
isFocused,
showNumbers: false,
wrapAround: true,
priority: true,
Expand Down Expand Up @@ -155,7 +158,7 @@ export function SearchableList<T extends GenericListItem>({
}
return false;
},
{ isActive: true },
{ isActive: isFocused },
);

const visibleItems = filteredItems.slice(
Expand Down Expand Up @@ -207,7 +210,7 @@ export function SearchableList<T extends GenericListItem>({
<TextInput
buffer={searchBuffer}
placeholder={searchPlaceholder}
focus={true}
focus={isFocused}
/>
</Box>
)}
Expand Down
119 changes: 119 additions & 0 deletions packages/cli/src/ui/components/views/ExtensionDetails.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import React from 'react';
import { render } from '../../../test-utils/render.js';
import { waitFor } from '../../../test-utils/async.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ExtensionDetails } from './ExtensionDetails.js';
import { KeypressProvider } from '../../contexts/KeypressContext.js';
import { type RegistryExtension } from '../../../config/extensionRegistryClient.js';

const mockExtension: RegistryExtension = {
id: 'ext1',
extensionName: 'Test Extension',
extensionDescription: 'A test extension description',
fullName: 'author/test-extension',
extensionVersion: '1.2.3',
rank: 1,
stars: 123,
url: 'https://github.com/author/test-extension',
repoDescription: 'Repo description',
avatarUrl: '',
lastUpdated: '2023-10-27',
hasMCP: true,
hasContext: true,
hasHooks: true,
hasSkills: true,
hasCustomCommands: true,
isGoogleOwned: true,
licenseKey: 'Apache-2.0',
};

describe('ExtensionDetails', () => {
let mockOnBack: ReturnType<typeof vi.fn>;
let mockOnInstall: ReturnType<typeof vi.fn>;

beforeEach(() => {
mockOnBack = vi.fn();
mockOnInstall = vi.fn();
});

const renderDetails = (isInstalled = false) =>
render(
<KeypressProvider>
<ExtensionDetails
extension={mockExtension}
onBack={mockOnBack}
onInstall={mockOnInstall}
isInstalled={isInstalled}
/>
</KeypressProvider>,
);

it('should render extension details correctly', async () => {
const { lastFrame } = renderDetails();
await waitFor(() => {
expect(lastFrame()).toContain('Test Extension');
expect(lastFrame()).toContain('v1.2.3');
expect(lastFrame()).toContain('123');
expect(lastFrame()).toContain('[G]');
expect(lastFrame()).toContain('author/test-extension');
expect(lastFrame()).toContain('A test extension description');
expect(lastFrame()).toContain('MCP');
expect(lastFrame()).toContain('Context file');
expect(lastFrame()).toContain('Hooks');
expect(lastFrame()).toContain('Skills');
expect(lastFrame()).toContain('Commands');
});
});

it('should show install prompt when not installed', async () => {
const { lastFrame } = renderDetails(false);
await waitFor(() => {
expect(lastFrame()).toContain('[Enter] Install');
expect(lastFrame()).not.toContain('Already Installed');
});
});

it('should show already installed message when installed', async () => {
const { lastFrame } = renderDetails(true);
await waitFor(() => {
expect(lastFrame()).toContain('Already Installed');
expect(lastFrame()).not.toContain('[Enter] Install');
});
});

it('should call onBack when Escape is pressed', async () => {
const { stdin } = renderDetails();
await React.act(async () => {
stdin.write('\x1b'); // Escape
});
await waitFor(() => {
expect(mockOnBack).toHaveBeenCalled();
});
});

it('should call onInstall when Enter is pressed and not installed', async () => {
const { stdin } = renderDetails(false);
await React.act(async () => {
stdin.write('\r'); // Enter
});
await waitFor(() => {
expect(mockOnInstall).toHaveBeenCalled();
});
});

it('should NOT call onInstall when Enter is pressed and already installed', async () => {
const { stdin } = renderDetails(true);
await React.act(async () => {
stdin.write('\r'); // Enter
});
// Wait a bit to ensure it's not called
await new Promise((resolve) => setTimeout(resolve, 100));
expect(mockOnInstall).not.toHaveBeenCalled();
});
});
174 changes: 174 additions & 0 deletions packages/cli/src/ui/components/views/ExtensionDetails.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import type React from 'react';
import { Box, Text } from 'ink';
import type { RegistryExtension } from '../../../config/extensionRegistryClient.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
import { theme } from '../../semantic-colors.js';

export interface ExtensionDetailsProps {
extension: RegistryExtension;
onBack: () => void;
onInstall: () => void;
isInstalled: boolean;
}

export function ExtensionDetails({
extension,
onBack,
onInstall,
isInstalled,
}: ExtensionDetailsProps): React.JSX.Element {
useKeypress(
(key) => {
if (keyMatchers[Command.ESCAPE](key)) {
onBack();
return true;
}
if (keyMatchers[Command.RETURN](key) && !isInstalled) {
onInstall();
return true;
}
return false;
},
{ isActive: true, priority: true },
);

return (
<Box
flexDirection="column"
paddingX={1}
paddingY={0}
height="100%"
borderStyle="round"
borderColor={theme.border.default}
>
{/* Header Row */}
<Box flexDirection="row" justifyContent="space-between" marginBottom={1}>
<Box>
<Text color={theme.text.secondary}>
{'>'} Extensions {'>'}{' '}
</Text>
<Text color={theme.text.primary} bold>
{extension.extensionName}
</Text>
</Box>
<Box flexDirection="row">
<Text color={theme.text.secondary}>
{extension.extensionVersion ? `v${extension.extensionVersion}` : ''}{' '}
|{' '}
</Text>
<Text color={theme.status.warning}>⭐ </Text>
<Text color={theme.text.secondary}>
{String(extension.stars || 0)} |{' '}
</Text>
{extension.isGoogleOwned && (
<Text color={theme.text.primary}>[G] </Text>
)}
<Text color={theme.text.primary}>{extension.fullName}</Text>
</Box>
</Box>

{/* Description */}
<Box marginBottom={1}>
<Text color={theme.text.primary}>
{extension.extensionDescription || extension.repoDescription}
</Text>
</Box>

{/* Features List */}
<Box flexDirection="row" marginBottom={1}>
{extension.hasMCP && (
<Box marginRight={1}>
<Text color={theme.text.primary}>MCP </Text>
<Text color={theme.text.secondary}>|</Text>
</Box>
)}
{extension.hasContext && (
<Box marginRight={1}>
<Text color={theme.status.error}>Context file </Text>
<Text color={theme.text.secondary}>|</Text>
</Box>
)}
{extension.hasHooks && (
<Box marginRight={1}>
<Text color={theme.status.warning}>Hooks </Text>
<Text color={theme.text.secondary}>|</Text>
</Box>
)}
{extension.hasSkills && (
<Box marginRight={1}>
<Text color={theme.status.success}>Skills </Text>
<Text color={theme.text.secondary}>|</Text>
</Box>
)}
{extension.hasCustomCommands && (
<Box marginRight={1}>
<Text color={theme.text.primary}>Commands</Text>
</Box>
)}
</Box>

{/* Details about MCP / Context */}
{extension.hasMCP && (
<Box flexDirection="column" marginBottom={1}>
<Text color={theme.text.primary}>
This extension will run the following MCP servers:
</Text>
<Box marginLeft={2}>
<Text color={theme.text.primary}>
* {extension.extensionName} (local)
</Text>
</Box>
</Box>
)}

{extension.hasContext && (
<Box flexDirection="column" marginBottom={1}>
<Text color={theme.text.primary}>
This extension will append info to your gemini.md context using
gemini.md
</Text>
</Box>
)}

{/* Spacer to push warning to bottom */}
<Box flexGrow={1} />

{/* Warning Box */}
{!isInstalled && (
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.status.warning}
paddingX={1}
paddingY={0}
>
<Text color={theme.text.primary}>
The extension you are about to install may have been created by a
third-party developer and sourced{'\n'}
from a public repository. Google does not vet, endorse, or guarantee
the functionality or security{'\n'}
of extensions. Please carefully inspect any extension and its source
code before installing to{'\n'}
understand the permissions it requires and the actions it may
perform.
</Text>
<Box marginTop={1}>
<Text color={theme.text.primary}>[{'Enter'}] Install</Text>
</Box>
</Box>
)}
{isInstalled && (
<Box flexDirection="row" marginTop={1} justifyContent="center">
<Text color={theme.status.success}>Already Installed</Text>
</Box>
)}
</Box>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -203,4 +203,31 @@ describe('ExtensionRegistryView', () => {
);
});
});

it('should call onSelect when extension is selected and Enter is pressed in details', async () => {
const { stdin, lastFrame } = renderView();

// Select the first extension in the list (Enter opens details)
await React.act(async () => {
stdin.write('\r');
});

// Verify we are in details view
await waitFor(() => {
expect(lastFrame()).toContain('author/ext1');
expect(lastFrame()).toContain('[Enter] Install');
});

// Ensure onSelect hasn't been called yet
expect(mockOnSelect).not.toHaveBeenCalled();

// Press Enter again in the details view to trigger install
await React.act(async () => {
stdin.write('\r');
});

await waitFor(() => {
expect(mockOnSelect).toHaveBeenCalledWith(mockExtensions[0]);
});
});
});
Loading
Loading