diff --git a/.changeset/hardening-egress-defaults.md b/.changeset/hardening-egress-defaults.md new file mode 100644 index 00000000..5357c822 --- /dev/null +++ b/.changeset/hardening-egress-defaults.md @@ -0,0 +1,13 @@ +--- +'@moonshot-ai/kimi-code': minor +--- + +Telemetry is opt-in in this fork. It previously ran unless the config said +`telemetry = false`, including when the config could not be read at all, so an +install that never made a choice still reported. It now runs only when the +config opts in; an unreadable config is treated as no consent rather than as +consent. `KIMI_DISABLE_TELEMETRY` continues to work as a hard override. + +`KIMI_CODE_NO_TIPS=1` disables the startup tips fetch, which was the one +network call at launch with no way to turn it off — so an install configured +for no outbound traffic still beaconed on every run. diff --git a/apps/kimi-code/src/cli/telemetry.ts b/apps/kimi-code/src/cli/telemetry.ts index fefec09e..12a6eac3 100644 --- a/apps/kimi-code/src/cli/telemetry.ts +++ b/apps/kimi-code/src/cli/telemetry.ts @@ -51,7 +51,8 @@ export function initializeCliTelemetry(options: InitializeCliTelemetryOptions): initializeTelemetry({ homeDir: options.harness.homeDir, deviceId: options.bootstrap.deviceId, - enabled: options.config.telemetry !== false, + // Opt-in in this fork: telemetry only runs when the user turns it on. + enabled: options.config.telemetry === true, appName: CLI_USER_AGENT_PRODUCT, version: options.version, uiMode: options.uiMode, @@ -100,7 +101,8 @@ export function initializeServerTelemetry( initializeTelemetry({ homeDir: bootstrap.homeDir, deviceId: bootstrap.deviceId, - enabled: config.telemetry !== false, + // Opt-in in this fork: telemetry only runs when the user turns it on. + enabled: config.telemetry === true, appName: CLI_USER_AGENT_PRODUCT, version: options.version, uiMode: WEB_UI_MODE, diff --git a/apps/kimi-code/src/tui/banner/banner-provider.ts b/apps/kimi-code/src/tui/banner/banner-provider.ts index daf54f95..66dfad00 100644 --- a/apps/kimi-code/src/tui/banner/banner-provider.ts +++ b/apps/kimi-code/src/tui/banner/banner-provider.ts @@ -312,6 +312,14 @@ export function selectDisplayableBanner({ return pickRandomCandidate(candidates, random); } +/** + * `KIMI_CODE_NO_TIPS` disables the startup tips fetch. Same truthy values as + * the other kill switches (`1` / `true` / `yes` / `on`). + */ +export function isTipsBannerDisabled(env: NodeJS.ProcessEnv = process.env): boolean { + return ['1', 'true', 'yes', 'on'].includes((env['KIMI_CODE_NO_TIPS'] ?? '').trim().toLowerCase()); +} + export class BannerProvider { constructor( private readonly clientVersion: string, @@ -322,6 +330,9 @@ export class BannerProvider { fetchImpl: typeof fetch = fetch, options: BannerProviderLoadOptions = {}, ): Promise { + // The banner is the one startup fetch with no way to turn it off, which + // makes an otherwise offline-configured install still beacon on every run. + if (isTipsBannerDisabled()) return null; try { const controller = new AbortController(); const timeout = setTimeout(() => { diff --git a/apps/kimi-code/test/cli/telemetry.test.ts b/apps/kimi-code/test/cli/telemetry.test.ts index 490aa83e..8d610d1e 100644 --- a/apps/kimi-code/test/cli/telemetry.test.ts +++ b/apps/kimi-code/test/cli/telemetry.test.ts @@ -104,7 +104,27 @@ describe('initializeServerTelemetry', () => { ); }); - it('degrades to enabled with no model when config is unreadable', async () => { + it('stays disabled when the config does not opt in', async () => { + mocks.loadRuntimeConfigSafe.mockReturnValue({ config: {}, fileError: undefined }); + const { initializeServerTelemetry } = await import('#/cli/telemetry'); + initializeServerTelemetry({ version: '1.2.3' }); + + expect(mocks.initializeTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ enabled: false }), + ); + }); + + it('enables only when the config opts in', async () => { + mocks.loadRuntimeConfigSafe.mockReturnValue({ config: { telemetry: true }, fileError: undefined }); + const { initializeServerTelemetry } = await import('#/cli/telemetry'); + initializeServerTelemetry({ version: '1.2.3' }); + + expect(mocks.initializeTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ enabled: true }), + ); + }); + + it('degrades to disabled with no model when config is unreadable', async () => { mocks.loadRuntimeConfigSafe.mockReturnValue({ config: {}, fileError: new Error('bad toml'), @@ -112,8 +132,9 @@ describe('initializeServerTelemetry', () => { const { initializeServerTelemetry } = await import('#/cli/telemetry'); initializeServerTelemetry({ version: '1.2.3' }); + // Telemetry is opt-in, so a config we could not read is not consent. expect(mocks.initializeTelemetry).toHaveBeenCalledWith( - expect.objectContaining({ enabled: true, model: undefined }), + expect.objectContaining({ enabled: false, model: undefined }), ); }); }); diff --git a/apps/kimi-code/test/tui/banner/banner-provider.test.ts b/apps/kimi-code/test/tui/banner/banner-provider.test.ts index b21de59d..b9fac8e7 100644 --- a/apps/kimi-code/test/tui/banner/banner-provider.test.ts +++ b/apps/kimi-code/test/tui/banner/banner-provider.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { selectBannerState, @@ -6,6 +6,7 @@ import { shouldDisplayBanner, } from '#/tui/banner/banner-provider'; import type { BannerState } from '#/tui/types'; +import { BannerProvider } from '#/tui/banner/banner-provider'; describe('selectBannerState', () => { const now = new Date('2026-06-15T12:00:00+08:00'); @@ -679,3 +680,18 @@ describe('selectDisplayableBanner', () => { expect(result).toMatchObject({ key: 'fallback-always', display: 'always' }); }); }); + +describe('tips banner kill switch', () => { + it('skips the fetch entirely when KIMI_CODE_NO_TIPS is set', async () => { + vi.stubEnv('KIMI_CODE_NO_TIPS', '1'); + try { + const fetchImpl = vi.fn(); + const provider = new BannerProvider('1.0.0'); + + await expect(provider.load(fetchImpl)).resolves.toBeNull(); + expect(fetchImpl).not.toHaveBeenCalled(); + } finally { + vi.unstubAllEnvs(); + } + }); +});