Add privacy notice slash command (#2059)

This commit is contained in:
Tommaso Sciortino 2025-06-27 12:07:38 -07:00 committed by GitHub
parent 4fbffdf617
commit a2a46c7c67
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 468 additions and 15 deletions

View File

@ -71,6 +71,7 @@ import { checkForUpdates } from './utils/updateCheck.js';
import ansiEscapes from 'ansi-escapes';
import { OverflowProvider } from './contexts/OverflowContext.js';
import { ShowMoreLines } from './components/ShowMoreLines.js';
import { PrivacyNotice } from './privacy/PrivacyNotice.js';
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
@ -130,6 +131,11 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
const [ctrlDPressedOnce, setCtrlDPressedOnce] = useState(false);
const ctrlDTimerRef = useRef<NodeJS.Timeout | null>(null);
const [constrainHeight, setConstrainHeight] = useState<boolean>(true);
const [showPrivacyNotice, setShowPrivacyNotice] = useState<boolean>(false);
const openPrivacyNotice = useCallback(() => {
setShowPrivacyNotice(true);
}, []);
const errorCount = useMemo(
() => consoleMessages.filter((msg) => msg.type === 'error').length,
@ -277,6 +283,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
toggleCorgiMode,
showToolDescriptions,
setQuittingMessages,
openPrivacyNotice,
);
const pendingHistoryItems = [...pendingSlashCommandHistoryItems];
@ -712,6 +719,11 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
onExit={exitEditorDialog}
/>
</Box>
) : showPrivacyNotice ? (
<PrivacyNotice
onExit={() => setShowPrivacyNotice(false)}
config={config}
/>
) : (
<>
<LoadingIndicator

View File

@ -76,6 +76,7 @@ export const useSlashCommandProcessor = (
toggleCorgiMode: () => void,
showToolDescriptions: boolean = false,
setQuittingMessages: (message: HistoryItem[]) => void,
openPrivacyNotice: () => void,
) => {
const session = useSessionStats();
const gitService = useMemo(() => {
@ -254,6 +255,13 @@ export const useSlashCommandProcessor = (
openEditorDialog();
},
},
{
name: 'privacy',
description: 'display the privacy notice',
action: (_mainCommand, _subCommand, _args) => {
openPrivacyNotice();
},
},
{
name: 'stats',
altName: 'usage',
@ -1022,6 +1030,7 @@ export const useSlashCommandProcessor = (
setQuittingMessages,
pendingCompressionItemRef,
setPendingCompressionItem,
openPrivacyNotice,
]);
const handleSlashCommand = useCallback(

View File

@ -0,0 +1,135 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { GaxiosError } from 'gaxios';
import { useState, useEffect, useCallback } from 'react';
import { Config, CodeAssistServer, UserTierId } from '@google/gemini-cli-core';
export interface PrivacyState {
isLoading: boolean;
error?: string;
isFreeTier?: boolean;
dataCollectionOptIn?: boolean;
}
export const usePrivacySettings = (config: Config) => {
const [privacyState, setPrivacyState] = useState<PrivacyState>({
isLoading: true,
});
useEffect(() => {
const fetchInitialState = async () => {
setPrivacyState({
isLoading: true,
});
try {
const server = getCodeAssistServer(config);
const tier = await getTier(server);
if (tier !== UserTierId.FREE) {
// We don't need to fetch opt-out info since non-free tier
// data gathering is already worked out some other way.
setPrivacyState({
isLoading: false,
isFreeTier: false,
});
return;
}
const optIn = await getRemoteDataCollectionOptIn(server);
setPrivacyState({
isLoading: false,
isFreeTier: true,
dataCollectionOptIn: optIn,
});
} catch (e) {
setPrivacyState({
isLoading: false,
error: e instanceof Error ? e.message : String(e),
});
}
};
fetchInitialState();
}, [config]);
const updateDataCollectionOptIn = useCallback(
async (optIn: boolean) => {
try {
const server = getCodeAssistServer(config);
const updatedOptIn = await setRemoteDataCollectionOptIn(server, optIn);
setPrivacyState({
isLoading: false,
isFreeTier: true,
dataCollectionOptIn: updatedOptIn,
});
} catch (e) {
setPrivacyState({
isLoading: false,
error: e instanceof Error ? e.message : String(e),
});
}
},
[config],
);
return {
privacyState,
updateDataCollectionOptIn,
};
};
function getCodeAssistServer(config: Config): CodeAssistServer {
const server = config.getGeminiClient().getContentGenerator();
// Neither of these cases should ever happen.
if (!(server instanceof CodeAssistServer)) {
throw new Error('Oauth not being used');
} else if (!server.projectId) {
throw new Error('Oauth not being used');
}
return server;
}
async function getTier(server: CodeAssistServer): Promise<UserTierId> {
const loadRes = await server.loadCodeAssist({
cloudaicompanionProject: server.projectId,
metadata: {
ideType: 'IDE_UNSPECIFIED',
platform: 'PLATFORM_UNSPECIFIED',
pluginType: 'GEMINI',
duetProject: server.projectId,
},
});
if (!loadRes.currentTier) {
throw new Error('User does not have a current tier');
}
return loadRes.currentTier.id;
}
async function getRemoteDataCollectionOptIn(
server: CodeAssistServer,
): Promise<boolean> {
try {
const resp = await server.getCodeAssistGlobalUserSetting();
return resp.freeTierDataCollectionOptin;
} catch (e) {
if (e instanceof GaxiosError) {
if (e.response?.status === 404) {
return true;
}
}
throw e;
}
}
async function setRemoteDataCollectionOptIn(
server: CodeAssistServer,
optIn: boolean,
): Promise<boolean> {
const resp = await server.setCodeAssistGlobalUserSetting({
cloudaicompanionProject: server.projectId,
freeTierDataCollectionOptin: optIn,
});
return resp.freeTierDataCollectionOptin;
}

View File

@ -0,0 +1,104 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Newline, Text } from 'ink';
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
import { usePrivacySettings } from '../hooks/usePrivacySettings.js';
import { CloudPaidPrivacyNotice } from './CloudPaidPrivacyNotice.js';
import { Config } from '@google/gemini-cli-core';
import { Colors } from '../colors.js';
interface CloudFreePrivacyNoticeProps {
config: Config;
onExit: () => void;
}
export const CloudFreePrivacyNotice = ({
config,
onExit,
}: CloudFreePrivacyNoticeProps) => {
const { privacyState, updateDataCollectionOptIn } =
usePrivacySettings(config);
if (privacyState.isLoading) {
return <Text color={Colors.Gray}>Loading...</Text>;
}
if (privacyState.error) {
return (
<Text color={Colors.AccentRed}>
Error loading Opt-in settings: {privacyState.error}
</Text>
);
}
if (privacyState.isFreeTier === false) {
return <CloudPaidPrivacyNotice onExit={onExit} />;
}
const items = [
{ label: 'Yes', value: true },
{ label: 'No', value: false },
];
return (
<Box flexDirection="column" marginY={1}>
<Text bold color={Colors.AccentPurple}>
Gemini Code Assist for Individuals Privacy Notice
</Text>
<Newline />
<Text>
This notice and our Privacy Policy
<Text color={Colors.AccentBlue}>[1]</Text> describe how Gemini Code
Assist handles your data. Please read them carefully.
</Text>
<Newline />
<Text>
When you use Gemini Code Assist for individuals with Gemini CLI, Google
collects your prompts, related code, generated output, code edits,
related feature usage information, and your feedback to provide,
improve, and develop Google products and services and machine learning
technologies.
</Text>
<Newline />
<Text>
To help with quality and improve our products (such as generative
machine-learning models), human reviewers may read, annotate, and
process the data collected above. We take steps to protect your privacy
as part of this process. This includes disconnecting the data from your
Google Account before reviewers see or annotate it, and storing those
disconnected copies for up to 18 months. Please don&apos;t submit
confidential information or any data you wouldn&apos;t want a reviewer
to see or Google to use to improve our products, services and
machine-learning technologies.
</Text>
<Newline />
<Box flexDirection="column">
<Text>
Allow Google to use this data to develop and improve our products?
</Text>
<RadioButtonSelect
items={items}
initialIndex={privacyState.dataCollectionOptIn ? 0 : 1}
onSelect={(value) => {
updateDataCollectionOptIn(value);
// Only exit if there was no error.
if (!privacyState.error) {
onExit();
}
}}
/>
</Box>
<Newline />
<Text>
<Text color={Colors.AccentBlue}>[1]</Text>{' '}
https://policies.google.com/privacy
</Text>
<Newline />
<Text color={Colors.Gray}>Press Enter to choose an option and exit.</Text>
</Box>
);
};

View File

@ -0,0 +1,55 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Newline, Text, useInput } from 'ink';
import { Colors } from '../colors.js';
interface CloudPaidPrivacyNoticeProps {
onExit: () => void;
}
export const CloudPaidPrivacyNotice = ({
onExit,
}: CloudPaidPrivacyNoticeProps) => {
useInput((input, key) => {
if (key.escape) {
onExit();
}
});
return (
<Box flexDirection="column" marginBottom={1}>
<Text bold color={Colors.AccentPurple}>
Vertex AI Notice
</Text>
<Newline />
<Text>
Service Specific Terms<Text color={Colors.AccentBlue}>[1]</Text> are
incorporated into the agreement under which Google has agreed to provide
Google Cloud Platform<Text color={Colors.AccentGreen}>[2]</Text> to
Customer (the Agreement). If the Agreement authorizes the resale or
supply of Google Cloud Platform under a Google Cloud partner or reseller
program, then except for in the section entitled Partner-Specific
Terms, all references to Customer in the Service Specific Terms mean
Partner or Reseller (as applicable), and all references to Customer Data
in the Service Specific Terms mean Partner Data. Capitalized terms used
but not defined in the Service Specific Terms have the meaning given to
them in the Agreement.
</Text>
<Newline />
<Text>
<Text color={Colors.AccentBlue}>[1]</Text>{' '}
https://cloud.google.com/terms/service-terms
</Text>
<Text>
<Text color={Colors.AccentGreen}>[2]</Text>{' '}
https://cloud.google.com/terms/services
</Text>
<Newline />
<Text color={Colors.Gray}>Press Esc to exit.</Text>
</Box>
);
};

View File

@ -0,0 +1,58 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Newline, Text, useInput } from 'ink';
import { Colors } from '../colors.js';
interface GeminiPrivacyNoticeProps {
onExit: () => void;
}
export const GeminiPrivacyNotice = ({ onExit }: GeminiPrivacyNoticeProps) => {
useInput((input, key) => {
if (key.escape) {
onExit();
}
});
return (
<Box flexDirection="column" marginBottom={1}>
<Text bold color={Colors.AccentPurple}>
Gemini API Key Notice
</Text>
<Newline />
<Text>
By using the Gemini API<Text color={Colors.AccentBlue}>[1]</Text>,
Google AI Studio
<Text color={Colors.AccentRed}>[2]</Text>, and the other Google
developer services that reference these terms (collectively, the
&quot;APIs&quot; or &quot;Services&quot;), you are agreeing to Google
APIs Terms of Service (the &quot;API Terms&quot;)
<Text color={Colors.AccentGreen}>[3]</Text>, and the Gemini API
Additional Terms of Service (the &quot;Additional Terms&quot;)
<Text color={Colors.AccentPurple}>[4]</Text>.
</Text>
<Newline />
<Text>
<Text color={Colors.AccentBlue}>[1]</Text>{' '}
https://ai.google.dev/docs/gemini_api_overview
</Text>
<Text>
<Text color={Colors.AccentRed}>[2]</Text> https://aistudio.google.com/
</Text>
<Text>
<Text color={Colors.AccentGreen}>[3]</Text>{' '}
https://developers.google.com/terms
</Text>
<Text>
<Text color={Colors.AccentPurple}>[4]</Text>{' '}
https://ai.google.dev/gemini-api/terms
</Text>
<Newline />
<Text color={Colors.Gray}>Press Esc to exit.</Text>
</Box>
);
};

View File

@ -0,0 +1,42 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box } from 'ink';
import { type Config, AuthType } from '@google/gemini-cli-core';
import { GeminiPrivacyNotice } from './GeminiPrivacyNotice.js';
import { CloudPaidPrivacyNotice } from './CloudPaidPrivacyNotice.js';
import { CloudFreePrivacyNotice } from './CloudFreePrivacyNotice.js';
interface PrivacyNoticeProps {
onExit: () => void;
config: Config;
}
const PrivacyNoticeText = ({
config,
onExit,
}: {
config: Config;
onExit: () => void;
}) => {
const authType = config.getContentGeneratorConfig()?.authType;
switch (authType) {
case AuthType.USE_GEMINI:
return <GeminiPrivacyNotice onExit={onExit} />;
case AuthType.USE_VERTEX_AI:
return <CloudPaidPrivacyNotice onExit={onExit} />;
case AuthType.LOGIN_WITH_GOOGLE_PERSONAL:
default:
return <CloudFreePrivacyNotice config={config} onExit={onExit} />;
}
};
export const PrivacyNotice = ({ onExit, config }: PrivacyNoticeProps) => (
<Box borderStyle="round" padding={1} flexDirection="column">
<PrivacyNoticeText config={config} onExit={onExit} />
</Box>
);

View File

@ -6,28 +6,30 @@
import { AuthClient } from 'google-auth-library';
import {
LoadCodeAssistResponse,
CodeAssistGlobalUserSettingResponse,
LoadCodeAssistRequest,
OnboardUserRequest,
LoadCodeAssistResponse,
LongrunningOperationResponse,
OnboardUserRequest,
SetCodeAssistGlobalUserSettingRequest,
} from './types.js';
import {
GenerateContentResponse,
GenerateContentParameters,
CountTokensParameters,
EmbedContentResponse,
CountTokensResponse,
EmbedContentParameters,
EmbedContentResponse,
GenerateContentParameters,
GenerateContentResponse,
} from '@google/genai';
import * as readline from 'readline';
import { ContentGenerator } from '../core/contentGenerator.js';
import {
CaCountTokenResponse,
CaGenerateContentResponse,
toGenerateContentRequest,
fromCountTokenResponse,
fromGenerateContentResponse,
toCountTokenRequest,
fromCountTokenResponse,
CaCountTokenResponse,
toGenerateContentRequest,
} from './converter.js';
import { PassThrough } from 'node:stream';
@ -93,6 +95,21 @@ export class CodeAssistServer implements ContentGenerator {
);
}
async getCodeAssistGlobalUserSetting(): Promise<CodeAssistGlobalUserSettingResponse> {
return await this.getEndpoint<CodeAssistGlobalUserSettingResponse>(
'getCodeAssistGlobalUserSetting',
);
}
async setCodeAssistGlobalUserSetting(
req: SetCodeAssistGlobalUserSettingRequest,
): Promise<CodeAssistGlobalUserSettingResponse> {
return await this.callEndpoint<CodeAssistGlobalUserSettingResponse>(
'setCodeAssistGlobalUserSetting',
req,
);
}
async countTokens(req: CountTokensParameters): Promise<CountTokensResponse> {
const resp = await this.callEndpoint<CaCountTokenResponse>(
'countTokens',
@ -126,6 +143,20 @@ export class CodeAssistServer implements ContentGenerator {
return res.data as T;
}
async getEndpoint<T>(method: string, signal?: AbortSignal): Promise<T> {
const res = await this.auth.request({
url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:${method}`,
method: 'GET',
headers: {
'Content-Type': 'application/json',
...this.httpOptions.headers,
},
responseType: 'json',
signal,
});
return res.data as T;
}
async streamEndpoint<T>(
method: string,
req: object,

View File

@ -173,3 +173,13 @@ export interface HelpLinkUrl {
description: string;
url: string;
}
export interface SetCodeAssistGlobalUserSettingRequest {
cloudaicompanionProject?: string;
freeTierDataCollectionOptin: boolean;
}
export interface CodeAssistGlobalUserSettingResponse {
cloudaicompanionProject?: string;
freeTierDataCollectionOptin: boolean;
}

View File

@ -226,11 +226,6 @@ export class Config {
}
async refreshAuth(authMethod: AuthType) {
// Check if this is actually a switch to a different auth method
const previousAuthType = this.contentGeneratorConfig?.authType;
const _isAuthMethodSwitch =
previousAuthType && previousAuthType !== authMethod;
// Always use the original default model when switching auth methods
// This ensures users don't stay on Flash after switching between auth types
// and allows API key users to get proper fallback behavior from getEffectiveModel

View File

@ -71,7 +71,8 @@ export class GeminiClient {
);
this.chat = await this.startChat();
}
private getContentGenerator(): ContentGenerator {
getContentGenerator(): ContentGenerator {
if (!this.contentGenerator) {
throw new Error('Content generator not initialized');
}

View File

@ -70,7 +70,6 @@ export async function createContentGeneratorConfig(
return contentGeneratorConfig;
}
//
if (authType === AuthType.USE_GEMINI && geminiApiKey) {
contentGeneratorConfig.apiKey = geminiApiKey;
contentGeneratorConfig.model = await getEffectiveModel(

View File

@ -21,6 +21,8 @@ export * from './core/nonInteractiveToolExecutor.js';
export * from './code_assist/codeAssist.js';
export * from './code_assist/oauth2.js';
export * from './code_assist/server.js';
export * from './code_assist/types.js';
// Export utilities
export * from './utils/paths.js';