Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/cli/src/services/BuiltinCommandLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { profileCommand } from '../ui/commands/profileCommand.js';
import { quitCommand } from '../ui/commands/quitCommand.js';
import { restoreCommand } from '../ui/commands/restoreCommand.js';
import { resumeCommand } from '../ui/commands/resumeCommand.js';
import { remoteControlCommand } from '../ui/commands/remoteControlCommand.js';
import { statsCommand } from '../ui/commands/statsCommand.js';
import { themeCommand } from '../ui/commands/themeCommand.js';
import { toolsCommand } from '../ui/commands/toolsCommand.js';
Expand Down Expand Up @@ -150,6 +151,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
privacyCommand,
...(isDevelopment ? [profileCommand] : []),
quitCommand,
remoteControlCommand,
restoreCommand(this.config),
resumeCommand,
statsCommand,
Expand Down
203 changes: 203 additions & 0 deletions packages/cli/src/services/RemoteControlService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import WebSocket from 'ws';
import {
ShellExecutionService,
coreEvents,
CoreEvent,
type OutputPayload,
type ConsoleLogPayload,
} from '@google/gemini-cli-core';
import { remoteControlEmitter, REMOTE_INPUT_EVENT } from './remoteControlEmitter.js';

interface RemoteCommand {
type: 'exec' | 'stdin' | 'resize' | 'input';
command?: string;
input?: string;
pid?: number;
cols?: number;
rows?: number;
}

export class RemoteControlService {
private ws: WebSocket | null = null;
private url: string;
private connected = false;
private activePids = new Set<number>();

constructor(url: string) {
this.url = url;
}

public connect(): Promise<void> {
return new Promise((resolve, reject) => {
try {
this.ws = new WebSocket(this.url);

this.ws.on('open', () => {
this.connected = true;
this.subscribeToCoreEvents();
resolve();
});

this.ws.on('message', (data: WebSocket.RawData) => {
this.handleMessage(data);
});

this.ws.on('close', () => {
this.connected = false;
this.unsubscribeFromCoreEvents();
});

this.ws.on('error', (err) => {
if (!this.connected) {
reject(err);
}
// If already connected, error will likely be followed by close
});
} catch (err) {
reject(err);
}
});
}

public disconnect(): void {
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.connected = false;
this.unsubscribeFromCoreEvents();
}

public isConnected(): boolean {
return this.connected;
}

private subscribeToCoreEvents() {
coreEvents.on(CoreEvent.Output, this.handleOutput);
coreEvents.on(CoreEvent.ConsoleLog, this.handleConsoleLog);
}

private unsubscribeFromCoreEvents() {
coreEvents.off(CoreEvent.Output, this.handleOutput);
coreEvents.off(CoreEvent.ConsoleLog, this.handleConsoleLog);
}

private handleOutput = (payload: OutputPayload) => {
// Forward global output to remote
this.sendMessage({
type: 'output',
stream: payload.isStderr ? 'stderr' : 'stdout',
chunk: payload.chunk.toString(),
});
};

private handleConsoleLog = (payload: ConsoleLogPayload) => {
// Forward console logs to remote
this.sendMessage({
type: 'log',
level: payload.type,
content: payload.content,
});
};

private handleMessage(data: WebSocket.RawData) {
try {
const message = JSON.parse(data.toString()) as RemoteCommand;
switch (message.type) {
case 'exec':
if (message.command) {
this.executeCommand(message.command);
}
break;
Comment on lines +113 to +117
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-critical critical

The RemoteControlService directly executes commands received from the WebSocket server without any validation or sanitization. This allows a malicious or compromised server to achieve Remote Code Execution (RCE) on the user's machine by sending an exec command.

Tracing the data flow:

  1. handleMessage (line 109) receives raw data from the WebSocket.
  2. JSON.parse (line 111) extracts the command property.
  3. executeCommand (line 115) is called with the untrusted command.
  4. ShellExecutionService.execute (line 150) runs the command in a shell.

This is a critical security vulnerability. You must implement a strict allow-list of permitted commands or ensure that the remote server is fully trusted and the connection is securely authenticated.

case 'stdin':
if (message.pid && message.input) {
ShellExecutionService.writeToPty(message.pid, message.input);
}
break;
Comment on lines +118 to +122
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The RemoteControlService allows the remote WebSocket server to inject arbitrary input into any active PTY process via the stdin message type. This can be used to hijack interactive sessions or execute commands in an already open shell.

Tracing the data flow:

  1. handleMessage (line 109) receives raw data.
  2. ShellExecutionService.writeToPty (line 120) is called with the untrusted message.input and message.pid.

This should be restricted to only allow input to processes explicitly managed by the remote session, and only after proper authorization.

case 'input':
if (message.input) {
remoteControlEmitter.emit(REMOTE_INPUT_EVENT, { input: message.input });
}
break;
case 'resize':
if (message.pid && message.cols && message.rows) {
ShellExecutionService.resizePty(
message.pid,
message.cols,
message.rows,
);
}
break;
default:
console.warn('Unknown remote command type:', message.type);
}
} catch (e) {
console.error('Failed to parse remote message:', e);
}
}

private async executeCommand(command: string) {
const cwd = process.cwd();
const abortController = new AbortController();

try {
const { pid, result } = await ShellExecutionService.execute(
command,
cwd,
(event) => {
if (pid) {
this.sendMessage({
type: 'shell_event',
pid,
event
});
}
},
abortController.signal,
true, // Use PTY if available
{
sanitizationConfig: {
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: [],
enableEnvironmentVariableRedaction: false,
},
Comment on lines +165 to +169
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The sanitizationConfig for ShellExecutionService.execute is configured to be overly permissive. It allows remotely executed commands to access all environment variables of the Gemini CLI process. This is a significant security risk, as it could expose sensitive information like API keys, authentication tokens, and other secrets stored in the environment.

For a remote execution feature, the environment should be heavily restricted by default. You should block all environment variables and only allow specific, safe ones if necessary.

Suggested change
sanitizationConfig: {
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: [],
enableEnvironmentVariableRedaction: false,
},
sanitizationConfig: {
allowedEnvironmentVariables: [], // Or a very small, safe subset
blockedEnvironmentVariables: ['*'], // Block all by default
enableEnvironmentVariableRedaction: true,
},

}
);

if (pid) {
this.activePids.add(pid);
}

const res = await result;

if (pid) {
this.activePids.delete(pid);
}

this.sendMessage({
type: 'exec_result',
pid,
exitCode: res.exitCode,
output: res.output
});

} catch (e) {
this.sendMessage({
type: 'exec_error',
error: e instanceof Error ? e.message : String(e)
});
}
}

private sendMessage(message: any) {
if (this.ws && this.connected) {
this.ws.send(JSON.stringify(message));
}
}
}
14 changes: 14 additions & 0 deletions packages/cli/src/services/remoteControlEmitter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { EventEmitter } from 'events';

export const remoteControlEmitter = new EventEmitter();
export const REMOTE_INPUT_EVENT = 'remote-input';

export interface RemoteInputPayload {
input: string;
}
15 changes: 15 additions & 0 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,11 @@ import { NewAgentsChoice } from './components/NewAgentsNotification.js';
import { isSlashCommand } from './utils/commandUtils.js';
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
import { isITerm2 } from './utils/terminalUtils.js';
import {
remoteControlEmitter,
REMOTE_INPUT_EVENT,
type RemoteInputPayload,
} from '../services/remoteControlEmitter.js';

function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
return pendingHistoryItems.some((item) => {
Expand Down Expand Up @@ -1106,6 +1111,16 @@ Logging in with Google... Restarting Gemini CLI to continue.
],
);

useEffect(() => {
const handleRemoteInput = (payload: RemoteInputPayload) => {
handleFinalSubmit(payload.input);
};
remoteControlEmitter.on(REMOTE_INPUT_EVENT, handleRemoteInput);
return () => {
remoteControlEmitter.off(REMOTE_INPUT_EVENT, handleRemoteInput);
};
}, [handleFinalSubmit]);
Comment on lines +1114 to +1122
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The AppContainer component listens for remote input events and passes them directly to handleFinalSubmit. This allows a remote attacker (via the WebSocket server) to execute any CLI slash command (e.g., /quit, /settings) or submit arbitrary prompts to the LLM.

Tracing the data flow:

  1. handleRemoteInput (line 1115) receives the payload from remoteControlEmitter.
  2. handleFinalSubmit (line 1116) processes the input as if it came from the local user.

This effectively grants full control over the CLI session to the remote server. Remote input should be strictly validated and restricted to a safe subset of operations.


const handleClearScreen = useCallback(() => {
historyManager.clearItems();
clearConsoleMessagesState();
Expand Down
61 changes: 61 additions & 0 deletions packages/cli/src/ui/commands/remoteControlCommand.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

// Hoist the mock factory
vi.mock('../../services/RemoteControlService.js', () => {
const RemoteControlService = vi.fn();
RemoteControlService.prototype.connect = vi.fn().mockResolvedValue(undefined);
RemoteControlService.prototype.disconnect = vi.fn();
RemoteControlService.prototype.isConnected = vi.fn().mockReturnValue(false);
return { RemoteControlService };
});

import { remoteControlCommand } from './remoteControlCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { MessageType } from '../types.js';
import { RemoteControlService } from '../../services/RemoteControlService.js';

describe('remoteControlCommand', () => {
let mockContext: any;
const originalEnv = process.env;

beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
mockContext = createMockCommandContext({
ui: {
addItem: vi.fn(),
},
} as any);
});

afterEach(() => {
process.env = originalEnv;
});
Comment on lines +25 to +39
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This test modifies process.env directly, which violates the repository's testing conventions. The style guide (lines 67-71) requires using vi.stubEnv() and vi.unstubAllEnvs() to manage environment variables in tests. This prevents test leakage and is more reliable.

Suggested change
const originalEnv = process.env;
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
mockContext = createMockCommandContext({
ui: {
addItem: vi.fn(),
},
} as any);
});
afterEach(() => {
process.env = originalEnv;
});
beforeEach(() => {
vi.clearAllMocks();
mockContext = createMockCommandContext({
ui: {
addItem: vi.fn(),
},
} as any);
});
afterEach(() => {
vi.unstubAllEnvs();
});
References
  1. When testing code that depends on environment variables, use vi.stubEnv('NAME', 'value') in beforeEach and vi.unstubAllEnvs() in afterEach. Avoid modifying process.env directly as it can lead to test leakage and is less reliable. (link)


it('should start a remote control session', async () => {
process.env['GEMINI_REMOTE_CONTROL_URL'] = 'ws://test-url';
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In accordance with the repository's testing conventions for environment variables (lines 67-71), please use vi.stubEnv() to set this environment variable for the test.

Suggested change
process.env['GEMINI_REMOTE_CONTROL_URL'] = 'ws://test-url';
vi.stubEnv('GEMINI_REMOTE_CONTROL_URL', 'ws://test-url');
References
  1. When testing code that depends on environment variables, use vi.stubEnv('NAME', 'value') in beforeEach and vi.unstubAllEnvs() in afterEach. Avoid modifying process.env directly as it can lead to test leakage and is less reliable. (link)


if (!remoteControlCommand.action) {
throw new Error('Command has no action');
}

// @ts-ignore
await remoteControlCommand.action(mockContext, '');

expect(RemoteControlService).toHaveBeenCalledWith(expect.stringMatching(/^ws:\/\/test-url\?token=/));
expect(RemoteControlService.prototype.connect).toHaveBeenCalled();

expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: expect.stringContaining('Connected to remote control service'),
}),
);
});
});
61 changes: 61 additions & 0 deletions packages/cli/src/ui/commands/remoteControlCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { RemoteControlService } from '../../services/RemoteControlService.js';
import { CommandKind, type SlashCommand } from './types.js';
import { MessageType } from '../types.js';
import { randomBytes } from 'crypto';

let service: RemoteControlService | null = null;

export const remoteControlCommand: SlashCommand = {
name: 'remote-control',
description: 'Start a remote control session via Google Chat',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context) => {
if (service && service.isConnected()) {
context.ui.addItem({
type: MessageType.INFO,
text: 'Remote control session is already active.',
});
return;
}

const token = randomBytes(16).toString('hex');
const baseUrl = process.env['GEMINI_REMOTE_CONTROL_URL'] || 'ws://localhost:8080';
const url = `${baseUrl}?token=${token}`;

context.ui.addItem({
type: MessageType.INFO,
text: `Connecting to remote control service at ${baseUrl}...`,
});
context.ui.addItem({
type: MessageType.INFO,
text: `Session Token: ${token}`,
});

service = new RemoteControlService(url);

try {
await service.connect();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

After a successful connection, a cleanup function should be registered to ensure service.disconnect() is called when the CLI exits. This prevents ungraceful connection termination and ensures resources are properly released. You'll need to import registerCleanup from ../../utils/cleanup.js.

      await service.connect();
      registerCleanup(() => service?.disconnect());

context.ui.addItem({
type: MessageType.INFO,
text: 'Connected to remote control service. You can now manage your session via Google Chat.',
});
context.ui.addItem({
type: MessageType.INFO,
text: 'Press Ctrl+C to stop the CLI (which will also stop the remote session).',
});
} catch (err) {
context.ui.addItem({
type: MessageType.ERROR,
text: `Failed to connect: ${err instanceof Error ? err.message : String(err)}`,
});
service = null;
}
},
};