diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx
index bef187ea226..904cb0319d1 100644
--- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx
+++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx
@@ -87,6 +87,122 @@ describe('ToolConfirmationMessage', () => {
unmount();
});
+ it('should display WarningMessage for deceptive URLs in info type', async () => {
+ const confirmationDetails: SerializableConfirmationDetails = {
+ type: 'info',
+ title: 'Confirm Web Fetch',
+ prompt: 'https://täst.com',
+ urls: ['https://täst.com'],
+ };
+
+ const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
+ ,
+ );
+
+ await waitUntilReady();
+
+ const output = lastFrame();
+ expect(output).toContain('Deceptive URL(s) detected');
+ expect(output).toContain('Original: https://täst.com');
+ expect(output).toContain(
+ 'Actual Host (Punycode): https://xn--tst-qla.com/',
+ );
+ unmount();
+ });
+
+ it('should display WarningMessage for deceptive URLs in exec type commands', async () => {
+ const confirmationDetails: SerializableConfirmationDetails = {
+ type: 'exec',
+ title: 'Confirm Execution',
+ command: 'curl https://еxample.com',
+ rootCommand: 'curl',
+ rootCommands: ['curl'],
+ };
+
+ const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
+ ,
+ );
+
+ await waitUntilReady();
+
+ const output = lastFrame();
+ expect(output).toContain('Deceptive URL(s) detected');
+ expect(output).toContain('Original: https://еxample.com/');
+ expect(output).toContain(
+ 'Actual Host (Punycode): https://xn--xample-2of.com/',
+ );
+ unmount();
+ });
+
+ it('should exclude shell delimiters from extracted URLs in exec type commands', async () => {
+ const confirmationDetails: SerializableConfirmationDetails = {
+ type: 'exec',
+ title: 'Confirm Execution',
+ command: 'curl https://еxample.com;ls',
+ rootCommand: 'curl',
+ rootCommands: ['curl'],
+ };
+
+ const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
+ ,
+ );
+
+ await waitUntilReady();
+
+ const output = lastFrame();
+ expect(output).toContain('Deceptive URL(s) detected');
+ // It should extract "https://еxample.com" and NOT "https://еxample.com;ls"
+ expect(output).toContain('Original: https://еxample.com/');
+ // The command itself still contains 'ls', so we check specifically that 'ls' is not part of the URL line.
+ expect(output).not.toContain('Original: https://еxample.com/;ls');
+ unmount();
+ });
+
+ it('should aggregate multiple deceptive URLs into a single WarningMessage', async () => {
+ const confirmationDetails: SerializableConfirmationDetails = {
+ type: 'info',
+ title: 'Confirm Web Fetch',
+ prompt: 'Fetch both',
+ urls: ['https://еxample.com', 'https://täst.com'],
+ };
+
+ const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
+ ,
+ );
+
+ await waitUntilReady();
+
+ const output = lastFrame();
+ expect(output).toContain('Deceptive URL(s) detected');
+ expect(output).toContain('Original: https://еxample.com/');
+ expect(output).toContain('Original: https://täst.com/');
+ unmount();
+ });
+
it('should display multiple commands for exec type when provided', async () => {
const confirmationDetails: SerializableConfirmationDetails = {
type: 'exec',
diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
index 42642d66f90..d54dd105706 100644
--- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
@@ -34,6 +34,12 @@ import {
} from '../../textConstants.js';
import { AskUserDialog } from '../AskUserDialog.js';
import { ExitPlanModeDialog } from '../ExitPlanModeDialog.js';
+import { WarningMessage } from './WarningMessage.js';
+import {
+ getDeceptiveUrlDetails,
+ toUnicodeUrl,
+ type DeceptiveUrlDetails,
+} from '../../utils/urlSecurityUtils.js';
export interface ToolConfirmationMessageProps {
callId: string;
@@ -99,6 +105,37 @@ export const ToolConfirmationMessage: React.FC<
[handleConfirm],
);
+ const deceptiveUrlWarnings = useMemo(() => {
+ const urls: string[] = [];
+ if (confirmationDetails.type === 'info' && confirmationDetails.urls) {
+ urls.push(...confirmationDetails.urls);
+ } else if (confirmationDetails.type === 'exec') {
+ const commands =
+ confirmationDetails.commands && confirmationDetails.commands.length > 0
+ ? confirmationDetails.commands
+ : [confirmationDetails.command];
+ for (const cmd of commands) {
+ const matches = cmd.match(/https?:\/\/[^\s"'`<>;&|()]+/g);
+ if (matches) urls.push(...matches);
+ }
+ }
+
+ const uniqueUrls = Array.from(new Set(urls));
+ return uniqueUrls
+ .map(getDeceptiveUrlDetails)
+ .filter((d): d is DeceptiveUrlDetails => d !== null);
+ }, [confirmationDetails]);
+
+ const deceptiveUrlWarningText = useMemo(() => {
+ if (deceptiveUrlWarnings.length === 0) return null;
+ return `**Warning:** Deceptive URL(s) detected:\n\n${deceptiveUrlWarnings
+ .map(
+ (w) =>
+ ` **Original:** ${w.originalUrl}\n **Actual Host (Punycode):** ${w.punycodeUrl}`,
+ )
+ .join('\n\n')}`;
+ }, [deceptiveUrlWarnings]);
+
const getOptions = useCallback(() => {
const options: Array> = [];
@@ -259,11 +296,21 @@ export const ToolConfirmationMessage: React.FC<
return Math.max(availableTerminalHeight - surroundingElementsHeight, 1);
}, [availableTerminalHeight, getOptions, handlesOwnUI]);
- const { question, bodyContent, options } = useMemo(() => {
+ const { question, bodyContent, options, securityWarnings } = useMemo<{
+ question: string;
+ bodyContent: React.ReactNode;
+ options: Array>;
+ securityWarnings: React.ReactNode;
+ }>(() => {
let bodyContent: React.ReactNode | null = null;
+ let securityWarnings: React.ReactNode | null = null;
let question = '';
const options = getOptions();
+ if (deceptiveUrlWarningText) {
+ securityWarnings = ;
+ }
+
if (confirmationDetails.type === 'ask_user') {
bodyContent = (
);
- return { question: '', bodyContent, options: [] };
+ return {
+ question: '',
+ bodyContent,
+ options: [],
+ securityWarnings: null,
+ };
}
if (confirmationDetails.type === 'exit_plan_mode') {
@@ -304,7 +356,7 @@ export const ToolConfirmationMessage: React.FC<
availableHeight={availableBodyContentHeight()}
/>
);
- return { question: '', bodyContent, options: [] };
+ return { question: '', bodyContent, options: [], securityWarnings: null };
}
if (confirmationDetails.type === 'edit') {
@@ -433,10 +485,10 @@ export const ToolConfirmationMessage: React.FC<
{displayUrls && infoProps.urls && infoProps.urls.length > 0 && (
URLs to fetch:
- {infoProps.urls.map((url) => (
-
+ {infoProps.urls.map((urlString) => (
+
{' '}
- -
+ -
))}
@@ -455,13 +507,14 @@ export const ToolConfirmationMessage: React.FC<
);
}
- return { question, bodyContent, options };
+ return { question, bodyContent, options, securityWarnings };
}, [
confirmationDetails,
getOptions,
availableBodyContentHeight,
terminalWidth,
handleConfirm,
+ deceptiveUrlWarningText,
]);
if (confirmationDetails.type === 'edit') {
@@ -505,6 +558,12 @@ export const ToolConfirmationMessage: React.FC<
+ {securityWarnings && (
+
+ {securityWarnings}
+
+ )}
+
{question}
diff --git a/packages/cli/src/ui/utils/urlSecurityUtils.test.ts b/packages/cli/src/ui/utils/urlSecurityUtils.test.ts
new file mode 100644
index 00000000000..3bec00a534a
--- /dev/null
+++ b/packages/cli/src/ui/utils/urlSecurityUtils.test.ts
@@ -0,0 +1,65 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect } from 'vitest';
+import { getDeceptiveUrlDetails, toUnicodeUrl } from './urlSecurityUtils.js';
+
+describe('urlSecurityUtils', () => {
+ describe('toUnicodeUrl', () => {
+ it('should convert a Punycode URL string to its Unicode version', () => {
+ expect(toUnicodeUrl('https://xn--tst-qla.com/')).toBe(
+ 'https://täst.com/',
+ );
+ });
+
+ it('should convert a URL object to its Unicode version', () => {
+ const urlObj = new URL('https://xn--tst-qla.com/path');
+ expect(toUnicodeUrl(urlObj)).toBe('https://täst.com/path');
+ });
+
+ it('should handle complex URLs with credentials and ports', () => {
+ const complexUrl = 'https://user:pass@xn--tst-qla.com:8080/path?q=1#hash';
+ expect(toUnicodeUrl(complexUrl)).toBe(
+ 'https://user:pass@täst.com:8080/path?q=1#hash',
+ );
+ });
+
+ it('should correctly reconstruct the URL even if the hostname appears in the path', () => {
+ const urlWithHostnameInPath =
+ 'https://xn--tst-qla.com/some/path/xn--tst-qla.com/index.html';
+ expect(toUnicodeUrl(urlWithHostnameInPath)).toBe(
+ 'https://täst.com/some/path/xn--tst-qla.com/index.html',
+ );
+ });
+
+ it('should return the original string if URL parsing fails', () => {
+ expect(toUnicodeUrl('not a url')).toBe('not a url');
+ });
+
+ it('should return the original string for already safe URLs', () => {
+ expect(toUnicodeUrl('https://google.com/')).toBe('https://google.com/');
+ });
+ });
+
+ describe('getDeceptiveUrlDetails', () => {
+ it('should return full details for a deceptive URL', () => {
+ const details = getDeceptiveUrlDetails('https://еxample.com');
+ expect(details).not.toBeNull();
+ expect(details?.originalUrl).toBe('https://еxample.com/');
+ expect(details?.punycodeUrl).toBe('https://xn--xample-2of.com/');
+ });
+
+ it('should return null for safe URLs', () => {
+ expect(getDeceptiveUrlDetails('https://google.com')).toBeNull();
+ });
+
+ it('should handle already Punycoded hostnames', () => {
+ const details = getDeceptiveUrlDetails('https://xn--tst-qla.com');
+ expect(details).not.toBeNull();
+ expect(details?.originalUrl).toBe('https://täst.com/');
+ });
+ });
+});
diff --git a/packages/cli/src/ui/utils/urlSecurityUtils.ts b/packages/cli/src/ui/utils/urlSecurityUtils.ts
new file mode 100644
index 00000000000..c3a5ca20a26
--- /dev/null
+++ b/packages/cli/src/ui/utils/urlSecurityUtils.ts
@@ -0,0 +1,90 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import url from 'node:url';
+
+/**
+ * Details about a deceptive URL.
+ */
+export interface DeceptiveUrlDetails {
+ /** The Unicode version of the visually deceptive URL. */
+ originalUrl: string;
+ /** The ASCII-safe Punycode version of the URL. */
+ punycodeUrl: string;
+}
+
+/**
+ * Whether a hostname contains non-ASCII or Punycode markers.
+ *
+ * @param hostname The hostname to check.
+ * @returns true if deceptive markers are found, false otherwise.
+ */
+function containsDeceptiveMarkers(hostname: string): boolean {
+ return (
+ // eslint-disable-next-line no-control-regex
+ hostname.toLowerCase().includes('xn--') || /[^\x00-\x7F]/.test(hostname)
+ );
+}
+
+/**
+ * Converts a URL (string or object) to its visually deceptive Unicode version.
+ *
+ * This function manually reconstructs the URL to bypass the automatic Punycode
+ * conversion performed by the WHATWG URL class when setting the hostname.
+ *
+ * @param urlInput The URL string or URL object to convert.
+ * @returns The reconstructed URL string with the hostname in Unicode.
+ */
+export function toUnicodeUrl(urlInput: string | URL): string {
+ try {
+ const urlObj = typeof urlInput === 'string' ? new URL(urlInput) : urlInput;
+ const punycodeHost = urlObj.hostname;
+ const unicodeHost = url.domainToUnicode(punycodeHost);
+
+ // Reconstruct the URL manually because the WHATWG URL class automatically
+ // Punycodes the hostname if we try to set it.
+ const protocol = urlObj.protocol + '//';
+ const credentials = urlObj.username
+ ? `${urlObj.username}${urlObj.password ? ':' + urlObj.password : ''}@`
+ : '';
+ const port = urlObj.port ? ':' + urlObj.port : '';
+
+ return `${protocol}${credentials}${unicodeHost}${port}${urlObj.pathname}${urlObj.search}${urlObj.hash}`;
+ } catch {
+ return typeof urlInput === 'string' ? urlInput : urlInput.href;
+ }
+}
+
+/**
+ * Extracts deceptive URL details if a URL hostname contains non-ASCII characters
+ * or is already in Punycode.
+ *
+ * @param urlString The URL string to check.
+ * @returns DeceptiveUrlDetails if a potential deceptive URL is detected, otherwise null.
+ */
+export function getDeceptiveUrlDetails(
+ urlString: string,
+): DeceptiveUrlDetails | null {
+ try {
+ if (!urlString.includes('://')) {
+ return null;
+ }
+
+ const urlObj = new URL(urlString);
+
+ if (!containsDeceptiveMarkers(urlObj.hostname)) {
+ return null;
+ }
+
+ return {
+ originalUrl: toUnicodeUrl(urlObj),
+ punycodeUrl: urlObj.href,
+ };
+ } catch {
+ // If URL parsing fails, it's not a valid URL we can safely analyze.
+ return null;
+ }
+}