diff --git a/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx b/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx index bea3227d78..ba008feade 100644 --- a/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx +++ b/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx @@ -21,10 +21,13 @@ vi.mock('../contexts/SessionContext.js', async (importOriginal) => { const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats); -const renderWithMockedStats = async (metrics: SessionMetrics) => { +const renderWithMockedStats = async ( + metrics: SessionMetrics, + sessionId = 'test-session', +) => { useSessionStatsMock.mockReturnValue({ stats: { - sessionId: 'test-session', + sessionId, sessionStartTime: new Date(), metrics, lastPromptTokenCount: 0, @@ -46,8 +49,30 @@ const renderWithMockedStats = async (metrics: SessionMetrics) => { }; describe('', () => { + const emptyMetrics: SessionMetrics = { + models: {}, + tools: { + totalCalls: 0, + totalSuccess: 0, + totalFail: 0, + totalDurationMs: 0, + totalDecisions: { + accept: 0, + reject: 0, + modify: 0, + [ToolCallDecision.AUTO_ACCEPT]: 0, + }, + byName: {}, + }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, + }; + it('renders the summary display with a title', async () => { const metrics: SessionMetrics = { + ...emptyMetrics, models: { 'gemini-2.5-pro': { api: { totalRequests: 10, totalErrors: 1, totalLatencyMs: 50234 }, @@ -63,19 +88,6 @@ describe('', () => { roles: {}, }, }, - tools: { - totalCalls: 0, - totalSuccess: 0, - totalFail: 0, - totalDurationMs: 0, - totalDecisions: { - accept: 0, - reject: 0, - modify: 0, - [ToolCallDecision.AUTO_ACCEPT]: 0, - }, - byName: {}, - }, files: { totalLinesAdded: 42, totalLinesRemoved: 15, @@ -89,4 +101,31 @@ describe('', () => { expect(output).toMatchSnapshot(); unmount(); }); + + it('renders a standard UUID-formatted session ID in the footer', async () => { + const uuidSessionId = 'a2b4-1b3d-e6g8-5f7h'; + const { lastFrame, unmount } = await renderWithMockedStats( + emptyMetrics, + uuidSessionId, + ); + const output = lastFrame(); + + // Standard UUID characters (alphanumeric and hyphens) should not be escaped. + expect(output).toContain('gemini --resume a2b4-1b3d-e6g8-5f7h'); + unmount(); + }); + + it('sanitizes a malicious session ID in the footer', async () => { + const maliciousSessionId = "'; rm -rf / #"; + const { lastFrame, unmount } = await renderWithMockedStats( + emptyMetrics, + maliciousSessionId, + ); + const output = lastFrame(); + + // We expect every non-alphanumeric character to be backslash-escaped + // to keep it a single argument without needing surrounding quotes. + expect(output).toContain("gemini --resume \\'\\;\\ rm\\ -rf\\ \\/\\ \\#"); + unmount(); + }); }); diff --git a/packages/cli/src/ui/components/SessionSummaryDisplay.tsx b/packages/cli/src/ui/components/SessionSummaryDisplay.tsx index 6c822a6517..6436936b7c 100644 --- a/packages/cli/src/ui/components/SessionSummaryDisplay.tsx +++ b/packages/cli/src/ui/components/SessionSummaryDisplay.tsx @@ -16,7 +16,7 @@ export const SessionSummaryDisplay: React.FC = ({ duration, }) => { const { stats } = useSessionStats(); - const footer = `To resume this session, run:\n gemini --resume ${stats.sessionId}`; + const footer = `To resume this session, run:\n gemini --resume ${stats.sessionId.replace(/([^a-zA-Z0-9.\-_])/g, '\\$1')}`; return (