feat(policy): map --yolo to allowedTools wildcard policy

This PR maps the `--yolo` flag natively into a wildcard policy array
(`allowedTools: ["*"]`) and removes the concept of `ApprovalMode.YOLO` as a
distinct state in the application, fulfilling issue #11303.

This removes the hardcoded `ApprovalMode.YOLO` state and its associated
UI/bypasses. The `PolicyEngine` now evaluates YOLO purely via data-driven rules.

- Removes `ApprovalMode.YOLO`
- Removes UI toggle (`Ctrl+Y`) and indicators for YOLO
- Removes `yolo.toml`
- Updates A2A server and CLI config logic to translate YOLO into a wildcard tool
- Rewrites policy engine tests to evaluate the wildcard
- Enforces enterprise `disableYoloMode` and `secureModeEnabled` controls
  by actively preventing manual `--allowed-tools=*` bypasses.

Fixes #11303
This commit is contained in:
Spencer
2026-03-19 02:43:14 +00:00
parent 1f5d7014c6
commit 4fde6c014c
86 changed files with 1125 additions and 2387 deletions
@@ -9,7 +9,6 @@ import {
type Config,
MessageBusType,
ToolConfirmationOutcome,
ApprovalMode,
Scheduler,
type MessageBus,
} from '@google/gemini-cli-core';
@@ -358,7 +357,7 @@ describe('Task Event-Driven Scheduler', () => {
// Enable YOLO mode
const yoloConfig = createMockConfig({
isEventDrivenSchedulerEnabled: () => true,
getApprovalMode: () => ApprovalMode.YOLO,
getAllowedTools: () => ['*'],
}) as Config;
const yoloMessageBus = yoloConfig.messageBus;
+16 -13
View File
@@ -10,7 +10,6 @@ import {
type GeminiClient,
GeminiEventType,
ToolConfirmationOutcome,
ApprovalMode,
getAllMCPServerStatuses,
MCPServerStatus,
isNodeError,
@@ -89,7 +88,8 @@ export class Task {
autoExecute: boolean;
private get isYoloMatch(): boolean {
return (
this.autoExecute || this.config.getApprovalMode() === ApprovalMode.YOLO
this.autoExecute ||
(this.config.getAllowedTools()?.includes('*') ?? false)
);
}
@@ -877,22 +877,25 @@ export class Task {
}
private async _handleToolConfirmationPart(part: Part): Promise<boolean> {
if (
part.kind !== 'data' ||
!part.data ||
// eslint-disable-next-line no-restricted-syntax
typeof part.data['callId'] !== 'string' ||
// eslint-disable-next-line no-restricted-syntax
typeof part.data['outcome'] !== 'string'
) {
const isToolConfirmationData = (
data: unknown,
): data is { callId: string; outcome: string; newContent?: unknown } => {
if (typeof data !== 'object' || data === null) return false;
const record = data as { callId?: unknown; outcome?: unknown };
return (
typeof record.callId === 'string' && typeof record.outcome === 'string'
);
};
if (part.kind !== 'data' || !isToolConfirmationData(part.data)) {
return false;
}
if (!part.data['outcome']) {
if (!part.data.outcome) {
return false;
}
const callId = part.data['callId'];
const outcomeString = part.data['outcome'];
const callId = part.data.callId;
const outcomeString = part.data.outcome;
this.toolsAlreadyConfirmed.add(callId);
+25 -51
View File
@@ -18,9 +18,7 @@ import {
type FetchAdminControlsResponse,
AuthType,
isHeadlessMode,
FatalAuthenticationError,
PolicyDecision,
PRIORITY_YOLO_ALLOW_ALL,
isCloudShell,
} from '@google/gemini-cli-core';
// Mock dependencies
@@ -57,6 +55,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
flush: vi.fn(),
},
isHeadlessMode: vi.fn().mockReturnValue(false),
isCloudShell: vi.fn().mockReturnValue(false),
FileDiscoveryService: vi.fn(),
getCodeAssistServer: vi.fn(),
fetchAdminControlsOnce: vi.fn(),
@@ -352,12 +351,12 @@ describe('loadConfig', () => {
});
describe('interactivity', () => {
it('should always set interactive true', async () => {
it('should set interactive false when headless', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(true);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
interactive: true,
interactive: false,
}),
);
@@ -390,35 +389,24 @@ describe('loadConfig', () => {
});
describe('YOLO mode', () => {
it('should enable YOLO mode and add policy rule when GEMINI_YOLO_MODE is true', async () => {
it('should enable wildcard allowedTools when GEMINI_YOLO_MODE is true', async () => {
vi.stubEnv('GEMINI_YOLO_MODE', 'true');
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
approvalMode: 'yolo',
policyEngineConfig: expect.objectContaining({
rules: expect.arrayContaining([
expect.objectContaining({
decision: PolicyDecision.ALLOW,
priority: PRIORITY_YOLO_ALLOW_ALL,
modes: ['yolo'],
allowRedirection: true,
}),
]),
}),
approvalMode: 'default',
allowedTools: expect.arrayContaining(['*']),
}),
);
});
it('should use default approval mode and empty rules when GEMINI_YOLO_MODE is not true', async () => {
it('should use default approval mode and undefined allowedTools when GEMINI_YOLO_MODE is not true', async () => {
vi.stubEnv('GEMINI_YOLO_MODE', 'false');
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
approvalMode: 'default',
policyEngineConfig: expect.objectContaining({
rules: [],
}),
allowedTools: undefined,
}),
);
});
@@ -449,69 +437,55 @@ describe('loadConfig', () => {
vi.unstubAllEnvs();
});
it('should attempt COMPUTE_ADC by default and bypass LOGIN_WITH_GOOGLE if successful', async () => {
it('should attempt LOGIN_WITH_GOOGLE by default in interactive mode', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(false);
const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
setupConfigMock(refreshAuthMock);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
expect(refreshAuthMock).not.toHaveBeenCalledWith(
expect(refreshAuthMock).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
expect(refreshAuthMock).not.toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
});
it('should fallback to LOGIN_WITH_GOOGLE if COMPUTE_ADC fails and interactive mode is available', async () => {
it('should attempt COMPUTE_ADC directly if GEMINI_CLI_USE_COMPUTE_ADC is true', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(false);
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
if (authType === AuthType.COMPUTE_ADC) {
return Promise.reject(new Error('ADC failed'));
}
return Promise.resolve();
});
vi.stubEnv('GEMINI_CLI_USE_COMPUTE_ADC', 'true');
const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
setupConfigMock(refreshAuthMock);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
expect(refreshAuthMock).toHaveBeenCalledWith(
expect(refreshAuthMock).not.toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
});
it('should throw FatalAuthenticationError in headless mode if COMPUTE_ADC fails', async () => {
it('should throw error in headless mode if not in CloudShell and USE_COMPUTE_ADC is false', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(true);
vi.mocked(isCloudShell).mockReturnValue(false);
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
if (authType === AuthType.COMPUTE_ADC) {
return Promise.reject(new Error('ADC not found'));
}
return Promise.resolve();
});
const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
setupConfigMock(refreshAuthMock);
await expect(
loadConfig(mockSettings, mockExtensionLoader, taskId),
).rejects.toThrow(
'COMPUTE_ADC failed: ADC not found. (LOGIN_WITH_GOOGLE fallback skipped due to headless mode. Run in an interactive terminal to use OAuth.)',
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
expect(refreshAuthMock).not.toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
'Interactive terminal required for LOGIN_WITH_GOOGLE',
);
});
it('should include both original and fallback error when LOGIN_WITH_GOOGLE fallback fails', async () => {
it('should throw error when COMPUTE_ADC fails directly', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(false);
vi.stubEnv('GEMINI_CLI_USE_COMPUTE_ADC', 'true');
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
if (authType === AuthType.COMPUTE_ADC) {
throw new Error('ADC failed');
}
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
throw new FatalAuthenticationError('OAuth failed');
}
return Promise.resolve();
});
setupConfigMock(refreshAuthMock);
@@ -519,7 +493,7 @@ describe('loadConfig', () => {
await expect(
loadConfig(mockSettings, mockExtensionLoader, taskId),
).rejects.toThrow(
'OAuth failed. The initial COMPUTE_ADC attempt also failed: ADC failed',
'COMPUTE_ADC failed: ADC failed. (Skipped LOGIN_WITH_GOOGLE due to GEMINI_CLI_USE_COMPUTE_ADC)',
);
});
});
+60 -52
View File
@@ -25,8 +25,7 @@ import {
ExperimentFlags,
isHeadlessMode,
FatalAuthenticationError,
PolicyDecision,
PRIORITY_YOLO_ALLOW_ALL,
isCloudShell,
type TelemetryTarget,
type ConfigParameters,
type ExtensionLoader,
@@ -42,6 +41,7 @@ export async function loadConfig(
taskId: string,
): Promise<Config> {
const workspaceDir = process.cwd();
const adcFilePath = process.env['GOOGLE_APPLICATION_CREDENTIALS'];
const folderTrust =
settings.folderTrust === true ||
@@ -60,11 +60,6 @@ export async function loadConfig(
}
}
const approvalMode =
process.env['GEMINI_YOLO_MODE'] === 'true'
? ApprovalMode.YOLO
: ApprovalMode.DEFAULT;
const configParams: ConfigParameters = {
sessionId: taskId,
clientName: 'a2a-server',
@@ -77,23 +72,12 @@ export async function loadConfig(
coreTools: settings.coreTools || settings.tools?.core || undefined,
excludeTools: settings.excludeTools || settings.tools?.exclude || undefined,
allowedTools: settings.allowedTools || settings.tools?.allowed || undefined,
allowedTools:
process.env['GEMINI_YOLO_MODE'] === 'true'
? [...(settings.allowedTools || settings.tools?.allowed || []), '*']
: settings.allowedTools || settings.tools?.allowed || undefined,
showMemoryUsage: settings.showMemoryUsage || false,
approvalMode,
policyEngineConfig: {
rules:
approvalMode === ApprovalMode.YOLO
? [
{
toolName: '*',
decision: PolicyDecision.ALLOW,
priority: PRIORITY_YOLO_ALLOW_ALL,
modes: [ApprovalMode.YOLO],
allowRedirection: true,
},
]
: [],
},
approvalMode: ApprovalMode.DEFAULT,
mcpServers: settings.mcpServers,
cwd: workspaceDir,
telemetry: {
@@ -123,7 +107,7 @@ export async function loadConfig(
trustedFolder: true,
extensionLoader,
checkpointing,
interactive: true,
interactive: !isHeadlessMode(),
enableInteractiveShell: !isHeadlessMode(),
ptyInfo: 'auto',
enableAgents: settings.experimental?.enableAgents ?? true,
@@ -190,7 +174,7 @@ export async function loadConfig(
await config.waitForMcpInit();
startupProfiler.flush(config);
await refreshAuthentication(config, 'Config');
await refreshAuthentication(config, adcFilePath, 'Config');
return config;
}
@@ -261,51 +245,75 @@ function findEnvFile(startDir: string): string | null {
async function refreshAuthentication(
config: Config,
adcFilePath: string | undefined,
logPrefix: string,
): Promise<void> {
if (process.env['USE_CCPA']) {
logger.info(`[${logPrefix}] Using CCPA Auth:`);
logger.info(`[${logPrefix}] Attempting COMPUTE_ADC first.`);
try {
await config.refreshAuth(AuthType.COMPUTE_ADC);
logger.info(`[${logPrefix}] COMPUTE_ADC successful.`);
} catch (adcError) {
const adcMessage =
adcError instanceof Error ? adcError.message : String(adcError);
logger.info(
`[${logPrefix}] COMPUTE_ADC failed or not available: ${adcMessage}`,
if (adcFilePath) {
path.resolve(adcFilePath);
}
} catch (e) {
logger.error(
`[${logPrefix}] USE_CCPA env var is true but unable to resolve GOOGLE_APPLICATION_CREDENTIALS file path ${adcFilePath}. Error ${e}`,
);
}
const useComputeAdc =
process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true';
const isHeadless = isHeadlessMode();
const useComputeAdc = process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true';
const isHeadless = isHeadlessMode();
const shouldSkipOauth = isHeadless || useComputeAdc;
if (isHeadless || useComputeAdc) {
const reason = isHeadless
? 'headless mode'
: 'GEMINI_CLI_USE_COMPUTE_ADC=true';
if (shouldSkipOauth) {
if (isCloudShell() || useComputeAdc) {
logger.info(
`[${logPrefix}] Skipping LOGIN_WITH_GOOGLE due to ${isHeadless ? 'headless mode' : 'GEMINI_CLI_USE_COMPUTE_ADC'}. Attempting COMPUTE_ADC.`,
);
try {
await config.refreshAuth(AuthType.COMPUTE_ADC);
logger.info(`[${logPrefix}] COMPUTE_ADC successful.`);
} catch (adcError) {
const adcMessage =
adcError instanceof Error ? adcError.message : String(adcError);
throw new FatalAuthenticationError(
`COMPUTE_ADC failed: ${adcMessage}. (Skipped LOGIN_WITH_GOOGLE due to ${isHeadless ? 'headless mode' : 'GEMINI_CLI_USE_COMPUTE_ADC'})`,
);
}
} else {
throw new FatalAuthenticationError(
`COMPUTE_ADC failed: ${adcMessage}. (LOGIN_WITH_GOOGLE fallback skipped due to ${reason}. Run in an interactive terminal to use OAuth.)`,
`Interactive terminal required for LOGIN_WITH_GOOGLE. Run in an interactive terminal or set GEMINI_CLI_USE_COMPUTE_ADC=true to use Application Default Credentials.`,
);
}
logger.info(
`[${logPrefix}] COMPUTE_ADC failed, falling back to LOGIN_WITH_GOOGLE.`,
);
} else {
try {
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
} catch (e) {
if (e instanceof FatalAuthenticationError) {
const originalMessage = e instanceof Error ? e.message : String(e);
throw new FatalAuthenticationError(
`${originalMessage}. The initial COMPUTE_ADC attempt also failed: ${adcMessage}`,
if (
e instanceof FatalAuthenticationError &&
(isCloudShell() || useComputeAdc)
) {
logger.warn(
`[${logPrefix}] LOGIN_WITH_GOOGLE failed. Attempting COMPUTE_ADC fallback.`,
);
try {
await config.refreshAuth(AuthType.COMPUTE_ADC);
logger.info(`[${logPrefix}] COMPUTE_ADC fallback successful.`);
} catch (adcError) {
logger.error(
`[${logPrefix}] COMPUTE_ADC fallback failed: ${adcError}`,
);
const originalMessage = e instanceof Error ? e.message : String(e);
const adcMessage =
adcError instanceof Error ? adcError.message : String(adcError);
throw new FatalAuthenticationError(
`${originalMessage}. Fallback to COMPUTE_ADC also failed: ${adcMessage}`,
);
}
} else {
throw e;
}
throw e;
}
}
logger.info(
`[${logPrefix}] GOOGLE_CLOUD_PROJECT: ${process.env['GOOGLE_CLOUD_PROJECT']}`,
);
+5 -2
View File
@@ -72,6 +72,7 @@ const getToolRegistrySpy = vi.fn().mockReturnValue({
getToolsByServer: vi.fn().mockReturnValue([]),
});
const getApprovalModeSpy = vi.fn();
const getAllowedToolsSpy = vi.fn();
const getShellExecutionConfigSpy = vi.fn();
const getExtensionsSpy = vi.fn();
@@ -83,6 +84,7 @@ vi.mock('../config/config.js', async () => {
const mockConfig = createMockConfig({
getToolRegistry: getToolRegistrySpy,
getApprovalMode: getApprovalModeSpy,
getAllowedTools: getAllowedToolsSpy,
getShellExecutionConfig: getShellExecutionConfigSpy,
getExtensions: getExtensionsSpy,
});
@@ -118,6 +120,7 @@ describe('E2E Tests', () => {
beforeEach(() => {
getApprovalModeSpy.mockReturnValue(ApprovalMode.DEFAULT);
getAllowedToolsSpy.mockReturnValue([]);
});
afterAll(
@@ -406,7 +409,7 @@ describe('E2E Tests', () => {
it('should handle multiple tool calls sequentially in YOLO mode', async () => {
// Set YOLO mode to auto-approve tools and test sequential execution.
getApprovalModeSpy.mockReturnValue(ApprovalMode.YOLO);
getAllowedToolsSpy.mockReturnValue(['*']);
// First call yields the tool request
sendMessageStreamSpy.mockImplementationOnce(async function* () {
@@ -697,7 +700,7 @@ describe('E2E Tests', () => {
});
// Set approval mode to yolo
getApprovalModeSpy.mockReturnValue(ApprovalMode.YOLO);
getAllowedToolsSpy.mockReturnValue(['*']);
const mockTool = new MockTool({
name: 'test-tool-yolo',
@@ -129,8 +129,8 @@ export function createMockConfig(
mockConfig.getPolicyEngine = vi.fn().mockReturnValue({
check: async () => {
const mode = mockConfig.getApprovalMode();
if (mode === ApprovalMode.YOLO) {
const allowed = mockConfig.getAllowedTools?.() || [];
if (allowed.includes('*')) {
return { decision: PolicyDecision.ALLOW };
}
return { decision: PolicyDecision.ASK_USER };