Compare commits

...

2 Commits

Author SHA1 Message Date
Christine Betts e62c566d39 Address comment 2026-04-02 12:38:04 -04:00
Christine Betts 60d40f3d4e Fix sandbox env var loading 2026-04-01 13:57:59 -04:00
8 changed files with 287 additions and 40 deletions
+23
View File
@@ -227,6 +227,29 @@ export SANDBOX_FLAGS="--flag1 --flag2=value"
$env:SANDBOX_FLAGS="--flag1 --flag2=value"
```
### Route custom environment variables
Use the `SANDBOX_ENV` environment variable to explicitly route custom
environment variables into the sandbox. This is a comma-separated list of
`KEY=VALUE` pairs.
**macOS/Linux**
```bash
export SANDBOX_ENV="MY_VAR=hello,ANOTHER_VAR=world"
gemini -p "echo \$MY_VAR \$ANOTHER_VAR"
```
**Windows (PowerShell)**
```powershell
$env:SANDBOX_ENV="MY_VAR=hello,ANOTHER_VAR=world"
gemini -p "echo %MY_VAR% %ANOTHER_VAR%"
```
Environment variables listed in `SANDBOX_ENV` are also available to tools run by
the agent.
## Linux UID/GID handling
The sandbox automatically handles user permissions on Linux. Override these
+3 -2
View File
@@ -85,14 +85,15 @@ const AUTH_ENV_VAR_WHITELIST = [
'GOOGLE_API_KEY',
'GOOGLE_CLOUD_PROJECT',
'GOOGLE_CLOUD_LOCATION',
'SANDBOX_ENV',
];
/**
* Sanitizes an environment variable value to prevent shell injection.
* Restricts values to a safe character set: alphanumeric, -, _, ., /
* Restricts values to a safe character set: alphanumeric, -, _, ., /, =, ,
*/
export function sanitizeEnvVar(value: string): string {
return value.replace(/[^a-zA-Z0-9\-_./]/g, '');
return value.replace(/[^a-zA-Z0-9\-_./=,]/g, '');
}
export function getSystemSettingsPath(): string {
+70
View File
@@ -514,6 +514,76 @@ describe('sandbox', () => {
);
});
it('should handle SANDBOX_ENV in macOS seatbelt', async () => {
vi.mocked(os.platform).mockReturnValue('darwin');
process.env['SANDBOX_ENV'] = 'MY_VAR=hello,ANOTHER_VAR=world';
const config: SandboxConfig = createMockSandboxConfig({
command: 'sandbox-exec',
image: 'some-image',
});
interface MockProcess extends EventEmitter {
stdout: EventEmitter;
stderr: EventEmitter;
}
const mockSpawnProcess = new EventEmitter() as MockProcess;
mockSpawnProcess.stdout = new EventEmitter();
mockSpawnProcess.stderr = new EventEmitter();
vi.mocked(spawn).mockReturnValue(
mockSpawnProcess as unknown as ReturnType<typeof spawn>,
);
const promise = start_sandbox(config);
setTimeout(() => mockSpawnProcess.emit('close', 0), 10);
await promise;
// Check that SANDBOX_ENV variables are passed in the sh -c command
expect(spawn).toHaveBeenCalledWith(
'sandbox-exec',
expect.arrayContaining([
'sh',
'-c',
expect.stringContaining('MY_VAR=hello ANOTHER_VAR=world'),
]),
expect.any(Object),
);
});
it('should handle SANDBOX_ENV in macOS seatbelt with commas in values', async () => {
vi.mocked(os.platform).mockReturnValue('darwin');
process.env['SANDBOX_ENV'] = 'MY_VAR=hello,world,ANOTHER_VAR=baz';
const config: SandboxConfig = createMockSandboxConfig({
command: 'sandbox-exec',
image: 'some-image',
});
interface MockProcess extends EventEmitter {
stdout: EventEmitter;
stderr: EventEmitter;
}
const mockSpawnProcess = new EventEmitter() as MockProcess;
mockSpawnProcess.stdout = new EventEmitter();
mockSpawnProcess.stderr = new EventEmitter();
vi.mocked(spawn).mockReturnValue(
mockSpawnProcess as unknown as ReturnType<typeof spawn>,
);
const promise = start_sandbox(config);
setTimeout(() => mockSpawnProcess.emit('close', 0), 10);
await promise;
// Check that SANDBOX_ENV variables are parsed correctly despite commas
expect(spawn).toHaveBeenCalledWith(
'sandbox-exec',
expect.arrayContaining([
'sh',
'-c',
expect.stringContaining('MY_VAR=hello\\,world ANOTHER_VAR=baz'),
]),
expect.any(Object),
);
});
it('should pass through GOOGLE_GEMINI_BASE_URL and GOOGLE_VERTEX_BASE_URL', async () => {
const config: SandboxConfig = createMockSandboxConfig({
command: 'docker',
+23 -38
View File
@@ -25,6 +25,7 @@ import {
FatalSandboxError,
GEMINI_DIR,
homedir,
parseSandboxEnv,
} from '@google/gemini-cli-core';
import { ConsolePatcher } from '../ui/utils/ConsolePatcher.js';
import { randomBytes } from 'node:crypto';
@@ -140,19 +141,21 @@ export async function start_sandbox(
args.push('-D', `INCLUDE_DIR_${i}=${dirPath}`);
}
const finalArgv = cliArgs;
const shCommandParts = [
'SANDBOX=sandbox-exec',
`NODE_OPTIONS=${quote([nodeOptions])}`,
];
args.push(
'-f',
profileFile,
'sh',
'-c',
[
`SANDBOX=sandbox-exec`,
`NODE_OPTIONS="${nodeOptions}"`,
...finalArgv.map((arg) => quote([arg])),
].join(' '),
);
// copy additional environment variables from SANDBOX_ENV
const parsedSandboxEnv = parseSandboxEnv(process.env['SANDBOX_ENV']);
for (const [key, value] of Object.entries(parsedSandboxEnv)) {
debugLogger.log(`SANDBOX_ENV: ${key}=${value}`);
shCommandParts.push(`${key}=${quote([value])}`);
}
shCommandParts.push(...cliArgs.map((arg) => quote([arg])));
args.push('-f', profileFile, 'sh', '-c', shCommandParts.join(' '));
// start and set up proxy if GEMINI_SANDBOX_PROXY_COMMAND is set
const proxyCommand = process.env['GEMINI_SANDBOX_PROXY_COMMAND'];
let proxyProcess: ChildProcess | undefined = undefined;
@@ -615,19 +618,10 @@ export async function start_sandbox(
}
// copy additional environment variables from SANDBOX_ENV
if (process.env['SANDBOX_ENV']) {
for (let env of process.env['SANDBOX_ENV'].split(',')) {
if ((env = env.trim())) {
if (env.includes('=')) {
debugLogger.log(`SANDBOX_ENV: ${env}`);
args.push('--env', env);
} else {
throw new FatalSandboxError(
'SANDBOX_ENV must be a comma-separated list of key=value pairs',
);
}
}
}
const parsedSandboxEnv = parseSandboxEnv(process.env['SANDBOX_ENV']);
for (const [key, value] of Object.entries(parsedSandboxEnv)) {
debugLogger.log(`SANDBOX_ENV: ${key}=${value}`);
args.push('--env', `${key}=${value}`);
}
// copy NODE_OPTIONS
@@ -978,20 +972,11 @@ async function start_lxc_sandbox(
}
// Forward SANDBOX_ENV key=value pairs
if (process.env['SANDBOX_ENV']) {
for (let env of process.env['SANDBOX_ENV'].split(',')) {
if ((env = env.trim())) {
if (env.includes('=')) {
envArgs.push('--env', env);
} else {
throw new FatalSandboxError(
'SANDBOX_ENV must be a comma-separated list of key=value pairs',
);
}
}
}
const parsedSandboxEnv = parseSandboxEnv(process.env['SANDBOX_ENV']);
for (const [key, value] of Object.entries(parsedSandboxEnv)) {
debugLogger.log(`SANDBOX_ENV: ${key}=${value}`);
envArgs.push('--env', `${key}=${value}`);
}
// Forward NODE_OPTIONS (e.g. from --inspect flags)
const existingNodeOptions = process.env['NODE_OPTIONS'] || '';
const allNodeOptions = [
+2
View File
@@ -105,6 +105,8 @@ export * from './utils/filesearch/fileSearch.js';
export * from './utils/errorParsing.js';
export * from './utils/fastAckHelper.js';
export * from './utils/workspaceContext.js';
export * from './utils/envUtils.js';
export * from './utils/envExpansion.js';
export * from './utils/environmentContext.js';
export * from './utils/ignorePatterns.js';
export * from './utils/partUtils.js';
@@ -22,6 +22,7 @@ import {
import { isBinary, truncateString } from '../utils/textUtils.js';
import pkg from '@xterm/headless';
import { debugLogger } from '../utils/debugLogger.js';
import { parseSandboxEnv } from '../utils/envUtils.js';
import { Storage } from '../config/storage.js';
import {
serializeTerminalToObject,
@@ -425,6 +426,12 @@ export class ShellExecutionService {
GIT_PAGER: shellExecutionConfig.pager ?? 'cat',
};
// Forward SANDBOX_ENV key=value pairs
const sandboxEnv = parseSandboxEnv(process.env['SANDBOX_ENV']);
for (const [key, value] of Object.entries(sandboxEnv)) {
baseEnv[key] = value;
}
if (!isInteractive) {
// Ensure all GIT_CONFIG_* variables are preserved even if they were redacted
for (const key of gitConfigKeys) {
+91
View File
@@ -0,0 +1,91 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { parseSandboxEnv } from './envUtils.js';
describe('parseSandboxEnv', () => {
it('should return an empty object for empty input', () => {
expect(parseSandboxEnv(undefined)).toEqual({});
expect(parseSandboxEnv('')).toEqual({});
});
it('should parse simple key-value pairs', () => {
const input = 'KEY1=VALUE1,KEY2=VALUE2';
const expected = {
KEY1: 'VALUE1',
KEY2: 'VALUE2',
};
expect(parseSandboxEnv(input)).toEqual(expected);
});
it('should handle values with commas using heuristic', () => {
const input = 'KEY1=VALUE1,WITH,COMMAS,KEY2=VALUE2';
const expected = {
KEY1: 'VALUE1,WITH,COMMAS',
KEY2: 'VALUE2',
};
expect(parseSandboxEnv(input)).toEqual(expected);
});
it('should forward host environment variables (limitations noted)', () => {
const hostEnv = {
HOST_VAR1: 'host_value1',
HOST_VAR2: 'host_value2',
};
// Put forwarded ones first to avoid ambiguity with values containing commas
const input = 'HOST_VAR1,KEY2=VALUE2';
const expected = {
HOST_VAR1: 'host_value1',
KEY2: 'VALUE2',
};
expect(parseSandboxEnv(input, hostEnv)).toEqual(expected);
// Note: If a forwarded var follows a KEY=VALUE, it's treated as part of the value
const input2 = 'KEY1=VALUE1,HOST_VAR2';
const expected2 = {
KEY1: 'VALUE1,HOST_VAR2',
};
expect(parseSandboxEnv(input2, hostEnv)).toEqual(expected2);
});
it('should handle keys and values with spaces (trimmed)', () => {
const input = ' KEY1 = VALUE1 , KEY2 = VALUE2 ';
const expected = {
KEY1: 'VALUE1',
KEY2: 'VALUE2',
};
expect(parseSandboxEnv(input)).toEqual(expected);
});
it('should handle multiple equals signs in a value', () => {
const input = 'KEY1=VALUE1=WITH=EQUALS,KEY2=VALUE2';
const expected = {
KEY1: 'VALUE1=WITH=EQUALS',
KEY2: 'VALUE2',
};
expect(parseSandboxEnv(input)).toEqual(expected);
});
it('should handle commas and equals in the middle (heuristic limitation)', () => {
const input = 'KEY1=VALUE1,KEY2=VALUE2';
const expected = {
KEY1: 'VALUE1',
KEY2: 'VALUE2',
};
expect(parseSandboxEnv(input)).toEqual(expected);
});
it('should demonstrate the heuristic limitation: equals after comma', () => {
// If a value contains '=' after a comma, it will be misparsed as a new key.
const input = 'KEY1=foo,bar=baz';
const expected = {
KEY1: 'foo',
bar: 'baz',
};
expect(parseSandboxEnv(input)).toEqual(expected);
});
});
+68
View File
@@ -0,0 +1,68 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Parses a SANDBOX_ENV string into a map of environment variables.
*
* The input string is a comma-separated list of either:
* 1. `KEY=VALUE` pairs.
* 2. `HOST_VAR_NAME` which forwards the variable from the host environment.
*
* Heuristic: A comma is treated as a separator ONLY if the next part contains an `=`.
* This allows values to contain commas (e.g., `VAR1=a,b,VAR2=c`).
*
* Note: If a value contains an `=` after a comma, it will be misparsed as a new key.
* This is a known limitation of the heuristic.
*
* @param input - The SANDBOX_ENV string to parse.
* @param hostEnv - The host environment variables (defaults to process.env).
* @returns A record of parsed environment variables.
*/
export function parseSandboxEnv(
input: string | undefined,
hostEnv: Record<string, string | undefined> = process.env,
): Record<string, string> {
const result: Record<string, string> = {};
if (!input) {
return result;
}
let currentPair = '';
const parts = input.split(',');
for (let part of parts) {
part = part.trim();
if (!part) continue;
if (part.includes('=')) {
// If we have a pending pair, process it first
if (currentPair) {
const [key, ...valParts] = currentPair.split('=');
result[key.trim()] = valParts.join('=').trim();
}
currentPair = part;
} else {
if (currentPair) {
// This part is a continuation of the previous value (it has a comma)
currentPair += ',' + part;
} else {
// This is a forwarded host variable
const val = hostEnv[part];
if (val !== undefined) {
result[part] = val;
}
}
}
}
// Process the last pair
if (currentPair) {
const [key, ...valParts] = currentPair.split('=');
result[key.trim()] = valParts.join('=').trim();
}
return result;
}