Skip to content

Commit 0273154

Browse files
committed
fix(cli): prompt for folder trust before auth
1 parent 5d4af9f commit 0273154

4 files changed

Lines changed: 358 additions & 0 deletions

File tree

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8+
import prompts from 'prompts';
9+
import { coreEvents, ExitCodes } from '@google/gemini-cli-core';
10+
import { relaunchApp } from '../utils/processUtils.js';
11+
import {
12+
isWorkspaceTrusted,
13+
loadTrustedFolders,
14+
TrustLevel,
15+
} from './trustedFolders.js';
16+
import { maybePromptForFolderTrust } from './folderTrustPrompt.js';
17+
import type { MergedSettings } from './settings.js';
18+
19+
vi.mock('prompts', () => ({
20+
default: vi.fn(),
21+
}));
22+
23+
vi.mock('../utils/processUtils.js', () => ({
24+
relaunchApp: vi.fn().mockResolvedValue(undefined),
25+
}));
26+
27+
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
28+
const actual =
29+
await importOriginal<typeof import('@google/gemini-cli-core')>();
30+
const mockCoreEvents = Object.create(
31+
actual.coreEvents,
32+
) as typeof actual.coreEvents;
33+
mockCoreEvents.emitFeedback = vi.fn();
34+
return {
35+
...actual,
36+
coreEvents: mockCoreEvents,
37+
};
38+
});
39+
40+
vi.mock('./trustedFolders.js', async (importOriginal) => {
41+
const actual = await importOriginal<typeof import('./trustedFolders.js')>();
42+
return {
43+
...actual,
44+
isWorkspaceTrusted: vi.fn(),
45+
loadTrustedFolders: vi.fn(),
46+
};
47+
});
48+
49+
describe('maybePromptForFolderTrust', () => {
50+
const settings = {
51+
security: {
52+
folderTrust: {
53+
enabled: true,
54+
},
55+
},
56+
} as MergedSettings;
57+
const argv = {
58+
prompt: undefined,
59+
query: undefined,
60+
isCommand: undefined,
61+
};
62+
const setValue = vi.fn().mockResolvedValue(undefined);
63+
const stdinIsTTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY');
64+
const stdoutIsTTY = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY');
65+
66+
beforeEach(() => {
67+
vi.clearAllMocks();
68+
Object.defineProperty(process.stdin, 'isTTY', {
69+
configurable: true,
70+
value: true,
71+
});
72+
Object.defineProperty(process.stdout, 'isTTY', {
73+
configurable: true,
74+
value: true,
75+
});
76+
vi.mocked(isWorkspaceTrusted).mockReturnValue({
77+
isTrusted: undefined,
78+
source: undefined,
79+
});
80+
vi.mocked(loadTrustedFolders).mockReturnValue({
81+
setValue,
82+
} as unknown as ReturnType<typeof loadTrustedFolders>);
83+
vi.mocked(prompts).mockResolvedValue({
84+
trustLevel: TrustLevel.DO_NOT_TRUST,
85+
});
86+
});
87+
88+
afterEach(() => {
89+
Object.defineProperty(process.stdin, 'isTTY', {
90+
configurable: true,
91+
value: stdinIsTTY?.value,
92+
});
93+
Object.defineProperty(process.stdout, 'isTTY', {
94+
configurable: true,
95+
value: stdoutIsTTY?.value,
96+
});
97+
});
98+
99+
it('does nothing when the workspace trust state is already known', async () => {
100+
vi.mocked(isWorkspaceTrusted).mockReturnValue({
101+
isTrusted: false,
102+
source: 'file',
103+
});
104+
105+
await maybePromptForFolderTrust(settings, argv, '/repo');
106+
107+
expect(prompts).not.toHaveBeenCalled();
108+
expect(setValue).not.toHaveBeenCalled();
109+
expect(relaunchApp).not.toHaveBeenCalled();
110+
});
111+
112+
it('saves an explicit do-not-trust decision and continues without relaunching', async () => {
113+
await maybePromptForFolderTrust(settings, argv, '/repo');
114+
115+
expect(prompts).toHaveBeenCalledExactlyOnceWith(
116+
expect.objectContaining({
117+
stdin: process.stdin,
118+
stdout: expect.anything(),
119+
}),
120+
);
121+
expect(setValue).toHaveBeenCalledWith('/repo', TrustLevel.DO_NOT_TRUST);
122+
expect(relaunchApp).not.toHaveBeenCalled();
123+
});
124+
125+
it('saves a trust decision and relaunches before authentication continues', async () => {
126+
vi.mocked(prompts).mockResolvedValue({
127+
trustLevel: TrustLevel.TRUST_FOLDER,
128+
});
129+
130+
await maybePromptForFolderTrust(settings, argv, '/repo');
131+
132+
expect(setValue).toHaveBeenCalledWith('/repo', TrustLevel.TRUST_FOLDER);
133+
expect(relaunchApp).toHaveBeenCalledOnce();
134+
});
135+
136+
it('exits with a config error if the trust decision cannot be saved', async () => {
137+
const processExitSpy = vi
138+
.spyOn(process, 'exit')
139+
.mockImplementation((code?: string | number | null) => {
140+
throw new Error(`process.exit:${code}`);
141+
});
142+
setValue.mockRejectedValueOnce(new Error('disk full'));
143+
144+
await expect(
145+
maybePromptForFolderTrust(settings, argv, '/repo'),
146+
).rejects.toThrow(`process.exit:${ExitCodes.FATAL_CONFIG_ERROR}`);
147+
148+
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
149+
'error',
150+
'Failed to save trust settings. Exiting Gemini CLI.',
151+
);
152+
processExitSpy.mockRestore();
153+
});
154+
});
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import * as path from 'node:path';
8+
import prompts from 'prompts';
9+
import {
10+
coreEvents,
11+
createWorkingStdio,
12+
ExitCodes,
13+
isHeadlessMode,
14+
} from '@google/gemini-cli-core';
15+
import { runExitCleanup } from '../utils/cleanup.js';
16+
import { relaunchApp } from '../utils/processUtils.js';
17+
import type { CliArgs } from './config.js';
18+
import type { MergedSettings } from './settings.js';
19+
import {
20+
isFolderTrustEnabled,
21+
isTrustLevel,
22+
isWorkspaceTrusted,
23+
loadTrustedFolders,
24+
TrustLevel,
25+
} from './trustedFolders.js';
26+
27+
type FolderTrustPromptArgs = Pick<CliArgs, 'prompt' | 'query' | 'isCommand'>;
28+
29+
async function exitAfterFailedTrustSave(): Promise<never> {
30+
coreEvents.emitFeedback(
31+
'error',
32+
'Failed to save trust settings. Exiting Gemini CLI.',
33+
);
34+
await runExitCleanup();
35+
process.exit(ExitCodes.FATAL_CONFIG_ERROR);
36+
}
37+
38+
async function exitAfterCancelledTrustPrompt(): Promise<never> {
39+
await runExitCleanup();
40+
process.exit(ExitCodes.FATAL_CANCELLATION_ERROR);
41+
}
42+
43+
/**
44+
* Resolves an unknown workspace trust state before slow startup work like auth.
45+
*
46+
* Trusting a folder requires a relaunch so startup can reload workspace settings,
47+
* hooks, MCP servers, and context files under the new trust decision.
48+
*/
49+
export async function maybePromptForFolderTrust(
50+
settings: MergedSettings,
51+
argv: FolderTrustPromptArgs,
52+
workspaceDir: string = process.cwd(),
53+
): Promise<void> {
54+
if (!isFolderTrustEnabled(settings)) {
55+
return;
56+
}
57+
58+
if (
59+
argv.isCommand ||
60+
isHeadlessMode({ prompt: argv.prompt, query: argv.query })
61+
) {
62+
return;
63+
}
64+
65+
const { isTrusted } = isWorkspaceTrusted(settings, workspaceDir, {
66+
prompt: argv.prompt,
67+
query: argv.query,
68+
});
69+
if (isTrusted !== undefined) {
70+
return;
71+
}
72+
73+
const dirName = path.basename(workspaceDir);
74+
const parentFolder = path.basename(path.dirname(workspaceDir));
75+
const { stdout } = createWorkingStdio();
76+
const response = await prompts({
77+
type: 'select',
78+
name: 'trustLevel',
79+
message: 'Do you want to trust this folder?',
80+
choices: [
81+
{
82+
title: `Trust folder (${dirName})`,
83+
value: TrustLevel.TRUST_FOLDER,
84+
},
85+
{
86+
title: `Trust parent folder (${parentFolder})`,
87+
value: TrustLevel.TRUST_PARENT,
88+
},
89+
{
90+
title: "Don't trust",
91+
value: TrustLevel.DO_NOT_TRUST,
92+
},
93+
],
94+
initial: 0,
95+
stdin: process.stdin,
96+
stdout,
97+
});
98+
99+
if (!isTrustLevel(response.trustLevel)) {
100+
await exitAfterCancelledTrustPrompt();
101+
}
102+
103+
try {
104+
await loadTrustedFolders().setValue(workspaceDir, response.trustLevel);
105+
} catch {
106+
await exitAfterFailedTrustSave();
107+
}
108+
109+
if (
110+
response.trustLevel === TrustLevel.TRUST_FOLDER ||
111+
response.trustLevel === TrustLevel.TRUST_PARENT
112+
) {
113+
await relaunchApp();
114+
}
115+
}

packages/cli/src/gemini.test.tsx

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,11 @@ vi.mock('./deferred.js', () => ({
265265
defer: vi.fn((m) => m),
266266
}));
267267

268+
const folderTrustPromptMock = vi.hoisted(() => ({
269+
maybePromptForFolderTrust: vi.fn().mockResolvedValue(undefined),
270+
}));
271+
vi.mock('./config/folderTrustPrompt.js', () => folderTrustPromptMock);
272+
268273
vi.mock('./ui/utils/mouse.js', () => ({
269274
enableMouseEvents: vi.fn(),
270275
disableMouseEvents: vi.fn(),
@@ -637,6 +642,87 @@ describe('gemini.tsx main function kitty protocol', () => {
637642
resumeSpy.mockRestore();
638643
});
639644

645+
it('should resolve folder trust before refreshing auth', async () => {
646+
const processExitSpy = vi
647+
.spyOn(process, 'exit')
648+
.mockImplementation((code) => {
649+
throw new MockProcessExitError(code);
650+
});
651+
const refreshAuth = vi.fn().mockResolvedValue(undefined);
652+
const partialConfig = createMockConfig({
653+
isInteractive: () => true,
654+
getQuestion: () => '',
655+
getSandbox: () => undefined,
656+
refreshAuth,
657+
});
658+
const finalConfig = createMockConfig({
659+
isInteractive: () => false,
660+
getQuestion: () => '',
661+
getSandbox: () => undefined,
662+
getListExtensions: () => true,
663+
getExtensions: () => [],
664+
});
665+
vi.mocked(loadCliConfig)
666+
.mockResolvedValueOnce(partialConfig)
667+
.mockResolvedValueOnce(finalConfig);
668+
vi.mocked(loadSandboxConfig).mockResolvedValue(undefined);
669+
vi.mocked(loadSettings).mockReturnValue(
670+
createMockSettings({
671+
merged: {
672+
advanced: {},
673+
security: { auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE } },
674+
ui: {},
675+
},
676+
}),
677+
);
678+
vi.mocked(parseArguments).mockResolvedValue({
679+
model: undefined,
680+
sandbox: undefined,
681+
debug: undefined,
682+
prompt: undefined,
683+
promptInteractive: undefined,
684+
query: undefined,
685+
yolo: undefined,
686+
approvalMode: undefined,
687+
policy: undefined,
688+
adminPolicy: undefined,
689+
allowedMcpServerNames: undefined,
690+
allowedTools: undefined,
691+
experimentalAcp: undefined,
692+
extensions: undefined,
693+
listExtensions: undefined,
694+
includeDirectories: undefined,
695+
screenReader: undefined,
696+
useWriteTodos: undefined,
697+
resume: undefined,
698+
sessionId: undefined,
699+
listSessions: undefined,
700+
deleteSession: undefined,
701+
outputFormat: undefined,
702+
fakeResponses: undefined,
703+
recordResponses: undefined,
704+
rawOutput: undefined,
705+
acceptRawOutputRisk: undefined,
706+
isCommand: undefined,
707+
skipTrust: undefined,
708+
});
709+
710+
try {
711+
await main();
712+
} catch (e) {
713+
if (!(e instanceof MockProcessExitError)) throw e;
714+
}
715+
716+
expect(folderTrustPromptMock.maybePromptForFolderTrust).toHaveBeenCalled();
717+
expect(refreshAuth).toHaveBeenCalled();
718+
expect(
719+
folderTrustPromptMock.maybePromptForFolderTrust.mock
720+
.invocationCallOrder[0],
721+
).toBeLessThan(refreshAuth.mock.invocationCallOrder[0]);
722+
expect(processExitSpy).toHaveBeenCalledWith(ExitCodes.SUCCESS);
723+
processExitSpy.mockRestore();
724+
});
725+
640726
it.each([
641727
{ flag: 'listExtensions' },
642728
{ flag: 'listSessions' },

packages/cli/src/gemini.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ import { relaunchOnExitCode } from './utils/relaunch.js';
9595
import { loadSandboxConfig } from './config/sandboxConfig.js';
9696
import { deleteSession, listSessions } from './utils/sessions.js';
9797
import { createPolicyUpdater } from './config/policy.js';
98+
import { maybePromptForFolderTrust } from './config/folderTrustPrompt.js';
9899

99100
import { setupTerminalAndTheme } from './utils/terminalTheme.js';
100101
import { runDeferredCommand } from './deferred.js';
@@ -464,6 +465,8 @@ export async function main() {
464465
process.exit(ExitCodes.FATAL_INPUT_ERROR);
465466
}
466467

468+
await maybePromptForFolderTrust(settings.merged, argv);
469+
467470
const isDebugMode = cliConfig.isDebugMode(argv);
468471
const consolePatcher = new ConsolePatcher({
469472
stderr: argv.isCommand ? false : true,

0 commit comments

Comments
 (0)