Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
51 changes: 51 additions & 0 deletions packages/core/src/services/shellExecutionService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2266,6 +2266,57 @@ describe('ShellExecutionService environment variables', () => {
vi.unstubAllEnvs();
});

it('should strip execution-affecting GIT_* variables from the spawned environment', async () => {
vi.resetModules();
// Ensure we exercise the default (non-strict) sanitization path, since
// strict sanitization (e.g. in CI, via GITHUB_SHA) would already strip
// these vars for unrelated reasons and mask a regression here.
vi.stubEnv('GITHUB_SHA', undefined);
vi.stubEnv('SURFACE', undefined);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

According to the repository's testing conventions, to "unset" an environment variable when using vi.stubEnv, you should use an empty string '' instead of undefined to avoid potential test leakage or unexpected behavior in Vitest.

Suggested change
vi.stubEnv('GITHUB_SHA', undefined);
vi.stubEnv('SURFACE', undefined);
vi.stubEnv('GITHUB_SHA', '');
vi.stubEnv('SURFACE', '');
References
  1. When testing code that depends on environment variables, use vi.stubEnv('NAME', 'value') and to 'unset' a variable, use an empty string vi.stubEnv('NAME', '').

vi.stubEnv('GIT_EXEC_PATH', '/tmp/evil');
vi.stubEnv('GIT_SSH_COMMAND', 'calc.exe');
vi.stubEnv('GIT_PROXY_COMMAND', 'cmd /c calc.exe');
vi.stubEnv('GIT_SSH_VARIANT', 'ssh');
vi.stubEnv('GIT_ALTERNATE_OBJECT_DIRECTORIES', '/tmp/evil-objects');
vi.stubEnv('GIT_TEMPLATE_DIR', '/tmp/evil-template');
vi.stubEnv('GIT_REPLACE_REF_BASE', 'refs/evil');
vi.stubEnv('GIT_CEILING_DIRECTORIES', '/tmp/evil-ceiling');
vi.stubEnv('PATH', '/test/path'); // An unrelated var that should be kept

const { ShellExecutionService } = await import(
'./shellExecutionService.js'
);

mockGetPty.mockResolvedValue(null); // Force child_process fallback
await ShellExecutionService.execute(
'test-cp-execution-affecting-git-vars',
'/',
vi.fn(),
new AbortController().signal,
false, // non-interactive
shellExecutionConfig,
);

expect(mockCpSpawn).toHaveBeenCalled();
const cpEnv = mockCpSpawn.mock.calls[0][2].env;
expect(cpEnv).not.toHaveProperty('GIT_EXEC_PATH');
expect(cpEnv).not.toHaveProperty('GIT_SSH_COMMAND');
expect(cpEnv).not.toHaveProperty('GIT_PROXY_COMMAND');
expect(cpEnv).not.toHaveProperty('GIT_SSH_VARIANT');
expect(cpEnv).not.toHaveProperty('GIT_ALTERNATE_OBJECT_DIRECTORIES');
expect(cpEnv).not.toHaveProperty('GIT_TEMPLATE_DIR');
expect(cpEnv).not.toHaveProperty('GIT_REPLACE_REF_BASE');
expect(cpEnv).not.toHaveProperty('GIT_CEILING_DIRECTORIES');
expect(cpEnv).toHaveProperty('PATH', '/test/path');

// Ensure child_process exits
mockChildProcess.emit('exit', 0, null);
mockChildProcess.emit('close', 0, null);
await new Promise(process.nextTick);

vi.unstubAllEnvs();
});

it('should include headless git and gh environment variables in interactive fallback mode', async () => {
vi.resetModules();
vi.stubEnv('GIT_TERMINAL_PROMPT', undefined);
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/services/shellExecutionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
sanitizeEnvironment,
type EnvironmentSanitizationConfig,
} from './environmentSanitization.js';
import { EXECUTION_AFFECTING_GIT_ENV_VARS } from '../utils/gitUtils.js';
import {
NoopSandboxManager,
type SandboxManager,
Expand Down Expand Up @@ -541,6 +542,15 @@ export class ShellExecutionService {
DBUS_SESSION_BUS_ADDRESS: '',
});

// GIT_* variables that control which binaries/helpers git executes must
// never be inherited (e.g. from a project's .env file), or a
// trusted-but-malicious repository could use them to run arbitrary
// commands via otherwise ordinary shell commands that happen to invoke
// git.
for (const key of EXECUTION_AFFECTING_GIT_ENV_VARS) {
delete baseEnv[key];
}

// 3. Prepare Sandboxed Command
const sandboxedCommand = await sandboxManager.prepareCommand({
command: resolvedExecutable,
Expand Down
50 changes: 50 additions & 0 deletions packages/core/src/utils/gitUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect } from 'vitest';
import { getSafeGitEnv } from './gitUtils.js';

describe('getSafeGitEnv', () => {
it('strips execution-affecting GIT_* variables from the base environment', () => {
const maliciousEnv: Record<string, string | undefined> = {
PATH: '/usr/bin',
GIT_EXEC_PATH: '/tmp/evil',
GIT_SSH_COMMAND: 'calc.exe',
GIT_PROXY_COMMAND: 'cmd /c calc.exe',
GIT_SSH_VARIANT: 'ssh',
GIT_ALTERNATE_OBJECT_DIRECTORIES: '/tmp/evil-objects',
GIT_TEMPLATE_DIR: '/tmp/evil-template',
GIT_REPLACE_REF_BASE: 'refs/evil/',
GIT_CEILING_DIRECTORIES: '/tmp',
};

const safeEnv = getSafeGitEnv(maliciousEnv);

expect(safeEnv['GIT_EXEC_PATH']).toBeUndefined();
expect(safeEnv['GIT_SSH_COMMAND']).toBeUndefined();
expect(safeEnv['GIT_PROXY_COMMAND']).toBeUndefined();
expect(safeEnv['GIT_SSH_VARIANT']).toBeUndefined();
expect(safeEnv['GIT_ALTERNATE_OBJECT_DIRECTORIES']).toBeUndefined();
expect(safeEnv['GIT_TEMPLATE_DIR']).toBeUndefined();
expect(safeEnv['GIT_REPLACE_REF_BASE']).toBeUndefined();
expect(safeEnv['GIT_CEILING_DIRECTORIES']).toBeUndefined();
expect(safeEnv['PATH']).toBe('/usr/bin');
});

it('still strips GIT_CONFIG_* variables from the base environment', () => {
const env: Record<string, string | undefined> = {
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'core.pager',
GIT_CONFIG_VALUE_0: 'evil',
GIT_CONFIG_PARAMETERS: 'evil',
};

const safeEnv = getSafeGitEnv(env);

expect(safeEnv['GIT_CONFIG_KEY_0']).not.toBe('core.pager');
expect(safeEnv['GIT_CONFIG_PARAMETERS']).toBeUndefined();
});
});
24 changes: 22 additions & 2 deletions packages/core/src/utils/gitUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,35 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import { spawnAsync } from './shell-utils.js';

// GIT_* variables that control which binaries/helpers git executes. These
// must never be inherited from a project's .env file (or elsewhere), or a
// trusted-but-malicious repository could use them to run arbitrary commands
// via otherwise ordinary, non-model git invocations.
export const EXECUTION_AFFECTING_GIT_ENV_VARS = new Set([
'GIT_EXEC_PATH',
'GIT_PROXY_COMMAND',
'GIT_SSH_COMMAND',
'GIT_SSH_VARIANT',
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
'GIT_TEMPLATE_DIR',
'GIT_REPLACE_REF_BASE',
'GIT_CEILING_DIRECTORIES',
]);
Comment on lines +15 to +25

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

security-high high

The EXECUTION_AFFECTING_GIT_ENV_VARS set defines the list of git environment variables that are stripped from the environment before executing shell commands to prevent arbitrary command execution. However, it does not include GIT_CONFIG_PARAMETERS.

In shellExecutionService.ts, all environment variables starting with GIT_CONFIG_ (which includes GIT_CONFIG_PARAMETERS) are explicitly collected and preserved in the execution environment. Since GIT_CONFIG_PARAMETERS is not stripped, a malicious repository could supply a .env file containing GIT_CONFIG_PARAMETERS (e.g., setting core.pager or core.sshCommand to a malicious executable), leading to arbitrary code execution when Gemini CLI runs any shell command that invokes git.

To remediate this, add 'GIT_CONFIG_PARAMETERS' to the EXECUTION_AFFECTING_GIT_ENV_VARS set.

Suggested change
export const EXECUTION_AFFECTING_GIT_ENV_VARS = new Set([
'GIT_EXEC_PATH',
'GIT_PROXY_COMMAND',
'GIT_SSH_COMMAND',
'GIT_SSH_VARIANT',
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
'GIT_TEMPLATE_DIR',
'GIT_REPLACE_REF_BASE',
'GIT_CEILING_DIRECTORIES',
]);
export const EXECUTION_AFFECTING_GIT_ENV_VARS = new Set([
'GIT_EXEC_PATH',
'GIT_PROXY_COMMAND',
'GIT_SSH_COMMAND',
'GIT_SSH_VARIANT',
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
'GIT_TEMPLATE_DIR',
'GIT_REPLACE_REF_BASE',
'GIT_CEILING_DIRECTORIES',
'GIT_CONFIG_PARAMETERS',
]);


export function getSafeGitEnv(
baseEnv: Record<string, string | undefined> = process.env,
): Record<string, string | undefined> {
const devNullPath = process.platform === 'win32' ? 'NUL' : '/dev/null';

// Strip pre-existing GIT_CONFIG_* and GIT_CONFIG_PARAMETERS variables to prevent environment pollution
// Strip pre-existing GIT_CONFIG_*, GIT_CONFIG_PARAMETERS, and
// execution-affecting GIT_* variables to prevent environment pollution.
const cleanedEnv: Record<string, string | undefined> = {};
for (const [key, value] of Object.entries(baseEnv)) {
if (!key.startsWith('GIT_CONFIG_') && key !== 'GIT_CONFIG_PARAMETERS') {
if (
!key.startsWith('GIT_CONFIG_') &&
key !== 'GIT_CONFIG_PARAMETERS' &&
!EXECUTION_AFFECTING_GIT_ENV_VARS.has(key)
) {
cleanedEnv[key] = value;
}
}
Expand Down