Compare commits

..

3 Commits

Author SHA1 Message Date
jacob314 413098e03a fix(shell): address rcFile security issues
- Only source rcFile if the workspace is a trusted folder

- Properly escape the rcFile path to prevent command injection
2026-04-21 17:18:35 -07:00
jacob314 66abea7a0e fix(shell): explicitly set PAGER=cat when rcFile is not used 2026-04-21 15:33:01 -07:00
Billy Biggs dc82a395ac Add support for a shellToolRcFile setting 2026-04-21 15:26:52 -07:00
12 changed files with 128 additions and 36 deletions
+3 -7
View File
@@ -1,6 +1,6 @@
# Preview release: v0.39.0-preview.1
# Preview release: v0.39.0-preview.0
Released: April 21, 2026
Released: April 14, 2026
Our preview release includes the latest, new, and experimental features. This
release may not be as stable as our [latest weekly release](latest.md).
@@ -31,10 +31,6 @@ npm install -g @google/gemini-cli@preview
## What's Changed
- fix(patch): cherry-pick a4e98c0 to release/v0.39.0-preview.0-pr-25138 to patch
version v0.39.0-preview.0 and create version 0.39.0-preview.1 by
@gemini-cli-robot in
[#25766](https://github.com/google-gemini/gemini-cli/pull/25766)
- refactor(plan): simplify policy priorities and consolidate read-only rules by
@ruomengz in [#24849](https://github.com/google-gemini/gemini-cli/pull/24849)
- feat(test-utils): add memory usage integration test harness by @sripasg in
@@ -258,4 +254,4 @@ npm install -g @google/gemini-cli@preview
[#25336](https://github.com/google-gemini/gemini-cli/pull/25336)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.38.0-preview.0...v0.39.0-preview.1
https://github.com/google-gemini/gemini-cli/compare/v0.38.0-preview.0...v0.39.0-preview.0
+5
View File
@@ -1452,6 +1452,11 @@ their corresponding top-level category object in your `settings.json` file.
performance.
- **Default:** `true`
- **`tools.shell.rcFile`** (string):
- **Description:** The path to a bash file (e.g., .bashrc) to source before
executing shell commands.
- **Default:** `undefined`
- **`tools.core`** (array):
- **Description:** Restrict the set of built-in tools with an allowlist. Match
semantics mirror tools.allowed; see the built-in tools documentation for
@@ -8,16 +8,7 @@ import { expect, describe, it, beforeEach, afterEach } from 'vitest';
import { TestRig } from './test-helper.js';
import { join } from 'node:path';
// Skip on macOS: every interactive test in this file is chronically flaky
// because the captured pty buffer contains the CLI's startup escape
// sequences (`q4;?m...true color warning`) instead of the streamed output,
// causing `expectText(...)` to time out. Reproducible across unrelated
// runs on `main` (24740161950, 24739323404) and on consecutive merge-queue
// gates for #25753 (24743605639, 24747624513) — different tests in the
// same describe fail on different runs. Not specific to any model.
const skipOnDarwin = process.platform === 'darwin';
describe.skipIf(skipOnDarwin)('Interactive Mode', () => {
describe('Interactive Mode', () => {
let rig: TestRig;
beforeEach(() => {
+1 -3
View File
@@ -134,9 +134,7 @@ describe('file-system', () => {
).toBeTruthy();
const newFileContent = rig.readFile(fileName);
// Trim to tolerate models that idiomatically append a trailing newline.
// This test is about path-with-spaces handling, not whitespace fidelity.
expect(newFileContent.trim()).toBe('hello');
expect(newFileContent).toBe('hello');
});
it('should perform a read-then-write sequence', async () => {
+2 -9
View File
@@ -81,10 +81,7 @@ describe('Plan Mode', () => {
await rig.run({
approvalMode: 'plan',
args:
'Create a file called plan.md in the plans directory with the ' +
'content "# Plan". Treat this as a Directive and write the file ' +
'immediately without proposing strategy or asking for confirmation.',
args: 'Create a file called plan.md in the plans directory.',
});
const toolLogs = rig.readToolLogs();
@@ -197,11 +194,7 @@ describe('Plan Mode', () => {
await rig.run({
approvalMode: 'plan',
args:
'Create a file called plan-no-session.md in the plans directory ' +
'with the content "# Plan". Treat this as a Directive and write ' +
'the file immediately without proposing strategy or asking for ' +
'confirmation.',
args: 'Create a file called plan-no-session.md in the plans directory.',
});
const toolLogs = rig.readToolLogs();
+1
View File
@@ -925,6 +925,7 @@ export async function loadCliConfig(
toolDiscoveryCommand: settings.tools?.discoveryCommand,
toolCallCommand: settings.tools?.callCommand,
mcpServerCommand,
shellToolRcFile: settings.tools?.shell?.rcFile,
mcpServers,
mcpEnablementCallbacks,
mcpEnabled,
+10
View File
@@ -1637,6 +1637,16 @@ const SETTINGS_SCHEMA = {
'Enable shell output efficiency optimizations for better performance.',
showInDialog: false,
},
rcFile: {
type: 'string',
label: 'Shell Tool RC File',
category: 'Tools',
requiresRestart: false,
default: undefined as string | undefined,
description:
'The path to a bash file (e.g., .bashrc) to source before executing shell commands.',
showInDialog: false,
},
},
},
+7
View File
@@ -602,6 +602,7 @@ export interface ConfigParameters {
toolDiscoveryCommand?: string;
toolCallCommand?: string;
mcpServerCommand?: string;
shellToolRcFile?: string;
mcpServers?: Record<string, MCPServerConfig>;
mcpEnablementCallbacks?: McpEnablementCallbacks;
userMemory?: string | HierarchicalMemory;
@@ -779,6 +780,7 @@ export class Config implements McpContext, AgentLoopContext {
private readonly toolDiscoveryCommand: string | undefined;
private readonly toolCallCommand: string | undefined;
private readonly mcpServerCommand: string | undefined;
private readonly shellToolRcFile: string | undefined;
private readonly mcpEnabled: boolean;
private readonly extensionsEnabled: boolean;
private mcpServers: Record<string, MCPServerConfig> | undefined;
@@ -1047,6 +1049,7 @@ export class Config implements McpContext, AgentLoopContext {
this.toolDiscoveryCommand = params.toolDiscoveryCommand;
this.toolCallCommand = params.toolCallCommand;
this.mcpServerCommand = params.mcpServerCommand;
this.shellToolRcFile = params.shellToolRcFile;
this.mcpServers = params.mcpServers;
this.mcpEnablementCallbacks = params.mcpEnablementCallbacks;
this.mcpEnabled = params.mcpEnabled ?? true;
@@ -2318,6 +2321,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.mcpServerCommand;
}
getShellToolRcFile(): string | undefined {
return this.shellToolRcFile;
}
/**
* The user configured MCP servers (via gemini settings files).
*
+66
View File
@@ -164,6 +164,8 @@ describe('ShellTool', () => {
addPersistentApproval: vi.fn(),
addSessionApproval: vi.fn(),
},
getShellToolRcFile: vi.fn().mockReturnValue(undefined),
isTrustedFolder: vi.fn().mockReturnValue(true),
} as unknown as Config;
const bus = createMockMessageBus();
@@ -486,6 +488,70 @@ EOF`;
expect(mockShellExecutionService.mock.calls[0][0]).toMatch(/\nEOF\n\)\n/);
});
it('should source rcfile when shellToolRcFile setting is present and folder is trusted', async () => {
const rcFilePath = '~/.geminirc';
(mockConfig.getShellToolRcFile as Mock).mockReturnValue(rcFilePath);
(mockConfig.isTrustedFolder as Mock).mockReturnValue(true);
const invocation = shellTool.build({ command: 'my-command' });
const promise = invocation.execute({ abortSignal: mockAbortSignal });
resolveShellExecution();
await promise;
expect(mockShellExecutionService).toHaveBeenCalledWith(
expect.stringContaining(
`source '${rcFilePath.replace('~', '/home/user')}'; export PAGER=cat`,
),
expect.any(String),
expect.any(Function),
expect.any(AbortSignal),
false,
expect.any(Object),
);
});
it('should NOT source rcfile when shellToolRcFile setting is present but folder is untrusted', async () => {
const rcFilePath = '~/.geminirc';
(mockConfig.getShellToolRcFile as Mock).mockReturnValue(rcFilePath);
(mockConfig.isTrustedFolder as Mock).mockReturnValue(false);
const invocation = shellTool.build({ command: 'my-command' });
const promise = invocation.execute({ abortSignal: mockAbortSignal });
resolveShellExecution();
await promise;
expect(mockShellExecutionService).not.toHaveBeenCalledWith(
expect.stringContaining(`source '${rcFilePath}'`),
expect.any(String),
expect.any(Function),
expect.any(AbortSignal),
false,
expect.any(Object),
);
});
it('should properly escape quotes in rcFilePath', async () => {
const rcFilePath = "/path/with/'quotes'/rc";
(mockConfig.getShellToolRcFile as Mock).mockReturnValue(rcFilePath);
(mockConfig.isTrustedFolder as Mock).mockReturnValue(true);
const invocation = shellTool.build({ command: 'my-command' });
const promise = invocation.execute({ abortSignal: mockAbortSignal });
resolveShellExecution();
await promise;
expect(mockShellExecutionService).toHaveBeenCalledWith(
expect.stringContaining(
`source '/path/with/'\\''quotes'\\''/rc'; export PAGER=cat`,
),
expect.any(String),
expect.any(Function),
expect.any(AbortSignal),
false,
expect.any(Object),
);
});
it('should format error messages correctly', async () => {
const error = new Error('wrapped command failed');
const invocation = shellTool.build({ command: 'user-command' });
+24 -2
View File
@@ -50,7 +50,12 @@ import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { getShellDefinition } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
import { toPathKey, isSubpath, resolveToRealPath } from '../utils/paths.js';
import {
homedir,
toPathKey,
isSubpath,
resolveToRealPath,
} from '../utils/paths.js';
import {
getProactiveToolSuggestions,
isNetworkReliantCommand,
@@ -462,13 +467,30 @@ export class ShellToolInvocation extends BaseToolInvocation<
const combinedController = new AbortController();
const onAbort = () => combinedController.abort();
let strippedCommandWithRc = strippedCommand;
if (!isWindows) {
strippedCommandWithRc = `export PAGER=cat GIT_PAGER=cat; ${strippedCommand}`;
const rcFilePath = this.context.config.getShellToolRcFile();
if (rcFilePath && this.context.config.isTrustedFolder()) {
let resolvedRcFilePath = rcFilePath;
if (rcFilePath === '~' || rcFilePath.startsWith('~/')) {
resolvedRcFilePath = homedir() + rcFilePath.substring(1);
}
const escapedRcFilePath = resolvedRcFilePath.replace(
/'/g,
() => "'\\''",
);
strippedCommandWithRc = `source '${escapedRcFilePath}'; ${strippedCommandWithRc}`;
}
}
try {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-shell-'));
tempFilePath = path.join(tempDir, 'pgrep.tmp');
// pgrep is not available on Windows, so we can't get background PIDs
const commandToExecute = this.wrapCommandForPgrep(
strippedCommand,
strippedCommandWithRc,
tempFilePath,
isWindows,
);
+2 -5
View File
@@ -11,10 +11,7 @@ import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { env } from 'node:process';
import { setTimeout as sleep } from 'node:timers/promises';
import {
PREVIEW_GEMINI_FLASH_MODEL,
GEMINI_DIR,
} from '@google/gemini-cli-core';
import { PREVIEW_GEMINI_MODEL, GEMINI_DIR } from '@google/gemini-cli-core';
export { GEMINI_DIR };
import * as pty from '@lydell/node-pty';
import stripAnsi from 'strip-ansi';
@@ -478,7 +475,7 @@ export class TestRig {
...(env['GEMINI_TEST_TYPE'] === 'integration'
? {
model: {
name: PREVIEW_GEMINI_FLASH_MODEL,
name: PREVIEW_GEMINI_MODEL,
},
}
: {}),
+6
View File
@@ -2509,6 +2509,12 @@
"markdownDescription": "Enable shell output efficiency optimizations for better performance.\n\n- Category: `Tools`\n- Requires restart: `no`\n- Default: `true`",
"default": true,
"type": "boolean"
},
"rcFile": {
"title": "Shell Tool RC File",
"description": "The path to a bash file (e.g., .bashrc) to source before executing shell commands.",
"markdownDescription": "The path to a bash file (e.g., .bashrc) to source before executing shell commands.\n\n- Category: `Tools`\n- Requires restart: `no`",
"type": "string"
}
},
"additionalProperties": false