Compare commits

...

6 Commits

Author SHA1 Message Date
mkorwel 12a2a9725b Merge branch 'main' of https://github.com/google-gemini/gemini-cli into mk-windows-sandboxing 2026-03-09 22:58:40 -07:00
mkorwel 1633cd88ac feat(core): implement progressive elevation and AI error awareness for Windows sandbox 2026-03-09 22:46:13 -07:00
mkorwel 5c0b0f98ec chore(core): add build script for Windows sandbox helper 2026-03-09 22:04:08 -07:00
mkorwel 1cb703b405 feat(core): implement native Windows sandboxing with restricted tokens 2026-03-09 19:51:24 -07:00
galz10 002a57efeb feat(core): integrate SandboxManager to sandbox all process-spawning tools
- Integrate `SandboxManager` into `Config` and `AgentLoopContext`.
- Refactor `ShellExecutionService` to use sandboxing for PTY and child process spawns.
- Update `GrepTool`, `ShellTool`, and `ToolRegistry` to execute commands via `SandboxManager`.
- Ensure consistent environment sanitization in `spawnAsync` and `execStreaming` utilities.
- Address PR review feedback and fix compilation/lint errors:
    - Respect user redaction settings in `NoopSandboxManager`.
    - Use idiomatic `async/await` in `GrepTool` availability checks.
    - Update license headers to 2026.
    - Fix cross-package build errors and exports.
    - Resolve all TypeScript and ESLint warnings/errors.
- Update `sandboxConfig.test.ts` to match new `SandboxConfig` schema.
2026-03-09 14:57:45 -07:00
galz10 863a0aa01e feat(core): implement SandboxManager interface and config schema
- Add `sandbox` block to `ConfigSchema` with `enabled`, `allowedPaths`,
  and `networkAccess` properties.
- Define the `SandboxManager` interface and request/response types.
- Implement `NoopSandboxManager` fallback that silently passes commands
  through but rigorously enforces environment variable sanitization via
  `sanitizeEnvironment`.
- Update config and sandbox tests to use the new `SandboxConfig` schema.
- Add `createMockSandboxConfig` utility to `test-utils` for cleaner test
  mocking across the monorepo.
2026-03-09 11:20:13 -07:00
39 changed files with 1788 additions and 144 deletions
+25 -1
View File
@@ -2324,6 +2324,7 @@
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@octokit/auth-token": "^6.0.0",
"@octokit/graphql": "^9.0.2",
@@ -2504,6 +2505,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -2553,6 +2555,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz",
"integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
@@ -2927,6 +2930,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz",
"integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
@@ -2960,6 +2964,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz",
"integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/resources": "2.5.0"
@@ -3014,6 +3019,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz",
"integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/resources": "2.5.0",
@@ -4210,6 +4216,7 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4483,6 +4490,7 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
@@ -5330,6 +5338,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -7933,6 +7942,7 @@
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -8565,6 +8575,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -9879,6 +9890,7 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.2.tgz",
"integrity": "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -10158,6 +10170,7 @@
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.11.tgz",
"integrity": "sha512-93LQlzT7vvZ1XJcmOMwN4s+6W334QegendeHOMnEJBlhnpIzr8bws6/aOEHG8ZCuVD/vNeeea5m1msHIdAY6ig==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.1",
"ansi-escapes": "^7.0.0",
@@ -13840,6 +13853,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -13850,6 +13864,7 @@
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"shell-quote": "^1.6.1",
"ws": "^7"
@@ -15938,6 +15953,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16161,7 +16177,8 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
"license": "0BSD",
"peer": true
},
"node_modules/tsx": {
"version": "4.20.3",
@@ -16169,6 +16186,7 @@
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -16328,6 +16346,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -16551,6 +16570,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -16664,6 +16684,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16676,6 +16697,7 @@
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -17320,6 +17342,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -17722,6 +17745,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -102,6 +102,7 @@ export class AddMemoryCommand implements Command {
const signal = abortController.signal;
await tool.buildAndExecute(result.toolArgs, signal, undefined, {
sanitizationConfig: DEFAULT_SANITIZATION_CONFIG,
sandboxManager: context.config.sandboxManager,
});
await refreshMemory(context.config);
return {
@@ -20,6 +20,7 @@ import {
tmpdir,
type Config,
type Storage,
NoopSandboxManager,
} from '@google/gemini-cli-core';
import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js';
import { expect, vi } from 'vitest';
@@ -73,6 +74,14 @@ export function createMockConfig(
}),
getGitService: vi.fn(),
validatePathAccess: vi.fn().mockReturnValue(undefined),
getShellExecutionConfig: vi.fn().mockReturnValue({
sandboxManager: new NoopSandboxManager(),
sanitizationConfig: {
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: [],
enableEnvironmentVariableRedaction: false,
},
}),
...overrides,
} as unknown as Config;
+1
View File
@@ -105,6 +105,7 @@ export class AddMemoryCommand implements Command {
await tool.buildAndExecute(result.toolArgs, signal, undefined, {
sanitizationConfig: DEFAULT_SANITIZATION_CONFIG,
sandboxManager: context.config.sandboxManager,
});
await refreshMemory(context.config);
return {
@@ -20,7 +20,12 @@ import {
import { createExtension } from '../test-utils/createExtension.js';
import { ExtensionManager } from './extension-manager.js';
import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js';
import { GEMINI_DIR, type Config, tmpdir } from '@google/gemini-cli-core';
import {
GEMINI_DIR,
type Config,
tmpdir,
NoopSandboxManager,
} from '@google/gemini-cli-core';
import { createTestMergedSettings, SettingScope } from './settings.js';
describe('ExtensionManager theme loading', () => {
@@ -117,6 +122,7 @@ describe('ExtensionManager theme loading', () => {
terminalHeight: 24,
showColor: false,
pager: 'cat',
sandboxManager: new NoopSandboxManager(),
sanitizationConfig: {
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: [],
+90 -12
View File
@@ -90,7 +90,13 @@ describe('loadSandboxConfig', () => {
process.env['GEMINI_SANDBOX'] = 'docker';
mockedCommandExistsSync.mockReturnValue(true);
const config = await loadSandboxConfig({}, {});
expect(config).toEqual({ command: 'docker', image: 'default/image' });
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
command: 'docker',
image: 'default/image',
});
expect(mockedCommandExistsSync).toHaveBeenCalledWith('docker');
});
@@ -113,7 +119,13 @@ describe('loadSandboxConfig', () => {
process.env['GEMINI_SANDBOX'] = 'lxc';
mockedCommandExistsSync.mockReturnValue(true);
const config = await loadSandboxConfig({}, {});
expect(config).toEqual({ command: 'lxc', image: 'default/image' });
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
command: 'lxc',
image: 'default/image',
});
expect(mockedCommandExistsSync).toHaveBeenCalledWith('lxc');
});
@@ -134,6 +146,9 @@ describe('loadSandboxConfig', () => {
);
const config = await loadSandboxConfig({}, { sandbox: true });
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
command: 'sandbox-exec',
image: 'default/image',
});
@@ -144,6 +159,9 @@ describe('loadSandboxConfig', () => {
mockedCommandExistsSync.mockReturnValue(true); // all commands exist
const config = await loadSandboxConfig({}, { sandbox: true });
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
command: 'sandbox-exec',
image: 'default/image',
});
@@ -153,14 +171,26 @@ describe('loadSandboxConfig', () => {
mockedOsPlatform.mockReturnValue('linux');
mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'docker');
const config = await loadSandboxConfig({ tools: { sandbox: true } }, {});
expect(config).toEqual({ command: 'docker', image: 'default/image' });
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
command: 'docker',
image: 'default/image',
});
});
it('should use podman if available and docker is not', async () => {
mockedOsPlatform.mockReturnValue('linux');
mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'podman');
const config = await loadSandboxConfig({}, { sandbox: true });
expect(config).toEqual({ command: 'podman', image: 'default/image' });
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
command: 'podman',
image: 'default/image',
});
});
it('should throw if sandbox: true but no command is found', async () => {
@@ -177,7 +207,13 @@ describe('loadSandboxConfig', () => {
it('should use the specified command if it exists', async () => {
mockedCommandExistsSync.mockReturnValue(true);
const config = await loadSandboxConfig({}, { sandbox: 'podman' });
expect(config).toEqual({ command: 'podman', image: 'default/image' });
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
command: 'podman',
image: 'default/image',
});
expect(mockedCommandExistsSync).toHaveBeenCalledWith('podman');
});
@@ -205,14 +241,26 @@ describe('loadSandboxConfig', () => {
process.env['GEMINI_SANDBOX'] = 'docker';
mockedCommandExistsSync.mockReturnValue(true);
const config = await loadSandboxConfig({}, {});
expect(config).toEqual({ command: 'docker', image: 'env/image' });
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
command: 'docker',
image: 'env/image',
});
});
it('should use image from package.json if env var is not set', async () => {
process.env['GEMINI_SANDBOX'] = 'docker';
mockedCommandExistsSync.mockReturnValue(true);
const config = await loadSandboxConfig({}, {});
expect(config).toEqual({ command: 'docker', image: 'default/image' });
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
command: 'docker',
image: 'default/image',
});
});
it('should return undefined if command is found but no image is configured', async () => {
@@ -234,7 +282,13 @@ describe('loadSandboxConfig', () => {
'should enable sandbox for value: %s',
async (value) => {
const config = await loadSandboxConfig({}, { sandbox: value });
expect(config).toEqual({ command: 'docker', image: 'default/image' });
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
command: 'docker',
image: 'default/image',
});
},
);
@@ -257,7 +311,13 @@ describe('loadSandboxConfig', () => {
it('should use runsc via CLI argument on Linux', async () => {
const config = await loadSandboxConfig({}, { sandbox: 'runsc' });
expect(config).toEqual({ command: 'runsc', image: 'default/image' });
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
command: 'runsc',
image: 'default/image',
});
expect(mockedCommandExistsSync).toHaveBeenCalledWith('runsc');
expect(mockedCommandExistsSync).toHaveBeenCalledWith('docker');
});
@@ -266,7 +326,13 @@ describe('loadSandboxConfig', () => {
process.env['GEMINI_SANDBOX'] = 'runsc';
const config = await loadSandboxConfig({}, {});
expect(config).toEqual({ command: 'runsc', image: 'default/image' });
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
command: 'runsc',
image: 'default/image',
});
expect(mockedCommandExistsSync).toHaveBeenCalledWith('runsc');
expect(mockedCommandExistsSync).toHaveBeenCalledWith('docker');
});
@@ -277,7 +343,13 @@ describe('loadSandboxConfig', () => {
{},
);
expect(config).toEqual({ command: 'runsc', image: 'default/image' });
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
command: 'runsc',
image: 'default/image',
});
expect(mockedCommandExistsSync).toHaveBeenCalledWith('runsc');
expect(mockedCommandExistsSync).toHaveBeenCalledWith('docker');
});
@@ -289,7 +361,13 @@ describe('loadSandboxConfig', () => {
{ sandbox: 'podman' },
);
expect(config).toEqual({ command: 'runsc', image: 'default/image' });
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
command: 'runsc',
image: 'default/image',
});
});
it('should reject runsc on macOS (Linux-only)', async () => {
+44 -5
View File
@@ -29,17 +29,28 @@ const VALID_SANDBOX_COMMANDS: ReadonlyArray<SandboxConfig['command']> = [
'sandbox-exec',
'runsc',
'lxc',
'windows-native',
];
function isSandboxCommand(value: string): value is SandboxConfig['command'] {
return (VALID_SANDBOX_COMMANDS as readonly string[]).includes(value);
function isSandboxCommand(
value: string,
): value is Exclude<SandboxConfig['command'], undefined> {
return (VALID_SANDBOX_COMMANDS as ReadonlyArray<string | undefined>).includes(
value,
);
}
function getSandboxCommand(
sandbox?: boolean | string | null,
): SandboxConfig['command'] | '' {
// If the SANDBOX env var is set, we're already inside the sandbox.
if (process.env['SANDBOX']) {
// If the SANDBOX env var is set, we're already inside a container-based sandbox.
// For native sandboxing (windows-native, sandbox-exec), we still need the command
// to be active in the child process to restrict tool calls.
if (
process.env['SANDBOX'] &&
process.env['SANDBOX'] !== 'windows-native' &&
process.env['SANDBOX'] !== 'sandbox-exec'
) {
return '';
}
@@ -91,6 +102,8 @@ function getSandboxCommand(
// note: runsc is NOT auto-detected, it must be explicitly specified
if (os.platform() === 'darwin' && commandExists.sync('sandbox-exec')) {
return 'sandbox-exec';
} else if (os.platform() === 'win32') {
return 'windows-native';
} else if (commandExists.sync('docker') && sandbox === true) {
return 'docker';
} else if (commandExists.sync('podman') && sandbox === true) {
@@ -124,5 +137,31 @@ export async function loadSandboxConfig(
process.env['GEMINI_SANDBOX_IMAGE_DEFAULT'] ??
packageJson?.config?.sandboxImageUri;
return command && image ? { command, image } : undefined;
const networkAccess =
process.env['GEMINI_SANDBOX_NETWORK'] === 'true' ||
settings.tools?.sandboxNetworkAccess === true;
const allowedPathsEnv = process.env['GEMINI_SANDBOX_ALLOWED_PATHS']
?.split(',')
.map((p) => p.trim())
.filter((p) => p.length > 0);
const allowedPaths =
allowedPathsEnv ?? settings.tools?.sandboxAllowedPaths ?? [];
const enabled =
(sandboxOption !== undefined && sandboxOption !== false) ||
command === 'windows-native' ||
command === 'sandbox-exec';
return command &&
(image || command === 'sandbox-exec' || command === 'windows-native')
? {
enabled,
allowedPaths,
networkAccess,
command,
image,
}
: undefined;
}
+20
View File
@@ -1261,6 +1261,26 @@ const SETTINGS_SCHEMA = {
`,
showInDialog: false,
},
sandboxNetworkAccess: {
type: 'boolean',
label: 'Sandbox Network Access',
category: 'Tools',
requiresRestart: true,
default: false,
description: 'Whether the sandbox has outbound network access.',
showInDialog: true,
},
sandboxAllowedPaths: {
type: 'array',
label: 'Sandbox Allowed Paths',
category: 'Tools',
requiresRestart: true,
default: [] as string[],
description: 'Additional host paths to allow the sandbox to access.',
showInDialog: true,
items: { type: 'string' },
mergeStrategy: MergeStrategy.UNION,
},
shell: {
type: 'object',
label: 'Shell',
+69 -13
View File
@@ -23,6 +23,7 @@ import {
} from './gemini.js';
import { loadCliConfig, parseArguments } from './config/config.js';
import { loadSandboxConfig } from './config/sandboxConfig.js';
import { createMockSandboxConfig } from '@google/gemini-cli-test-utils';
import { terminalCapabilityManager } from './ui/utils/terminalCapabilityManager.js';
import { start_sandbox } from './utils/sandbox.js';
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
@@ -189,12 +190,19 @@ vi.mock('./ui/utils/terminalCapabilityManager.js', () => ({
vi.mock('./config/config.js', () => ({
loadCliConfig: vi.fn().mockImplementation(async () => createMockConfig()),
parseArguments: vi.fn().mockResolvedValue({}),
parseArguments: vi.fn().mockResolvedValue({
enabled: true,
allowedPaths: [],
networkAccess: false,
}),
isDebugMode: vi.fn(() => false),
}));
vi.mock('read-package-up', () => ({
readPackageUp: vi.fn().mockResolvedValue({
enabled: true,
allowedPaths: [],
networkAccess: false,
packageJson: { name: 'test-pkg', version: 'test-version' },
path: '/fake/path/package.json',
}),
@@ -232,6 +240,9 @@ vi.mock('./utils/relaunch.js', () => ({
vi.mock('./config/sandboxConfig.js', () => ({
loadSandboxConfig: vi.fn().mockResolvedValue({
enabled: true,
allowedPaths: [],
networkAccess: false,
command: 'docker',
image: 'test-image',
}),
@@ -536,6 +547,9 @@ describe('gemini.tsx main function kitty protocol', () => {
);
vi.mocked(parseArguments).mockResolvedValue({
enabled: true,
allowedPaths: [],
networkAccess: false,
promptInteractive: false,
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
@@ -599,6 +613,9 @@ describe('gemini.tsx main function kitty protocol', () => {
});
vi.mocked(parseArguments).mockResolvedValue({
enabled: true,
allowedPaths: [],
networkAccess: false,
promptInteractive: false,
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
@@ -618,14 +635,17 @@ describe('gemini.tsx main function kitty protocol', () => {
const mockConfig = createMockConfig({
isInteractive: () => false,
getQuestion: () => '',
getSandbox: () => ({ command: 'docker', image: 'test-image' }),
getSandbox: () =>
createMockSandboxConfig({ command: 'docker', image: 'test-image' }),
});
vi.mocked(loadCliConfig).mockResolvedValue(mockConfig);
vi.mocked(loadSandboxConfig).mockResolvedValue({
command: 'docker',
image: 'test-image',
});
vi.mocked(loadSandboxConfig).mockResolvedValue(
createMockSandboxConfig({
command: 'docker',
image: 'test-image',
}),
);
process.env['GEMINI_API_KEY'] = 'test-key';
try {
@@ -666,6 +686,9 @@ describe('gemini.tsx main function kitty protocol', () => {
);
vi.mocked(parseArguments).mockResolvedValue({
enabled: true,
allowedPaths: [],
networkAccess: false,
promptInteractive: false,
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
vi.mocked(loadCliConfig).mockResolvedValue(
@@ -721,6 +744,9 @@ describe('gemini.tsx main function kitty protocol', () => {
);
vi.mocked(parseArguments).mockResolvedValue({
enabled: true,
allowedPaths: [],
networkAccess: false,
promptInteractive: false,
resume: 'session-id',
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
@@ -777,6 +803,9 @@ describe('gemini.tsx main function kitty protocol', () => {
);
vi.mocked(parseArguments).mockResolvedValue({
enabled: true,
allowedPaths: [],
networkAccess: false,
promptInteractive: false,
resume: 'latest',
} as unknown as CliArgs);
@@ -827,6 +856,9 @@ describe('gemini.tsx main function kitty protocol', () => {
);
vi.mocked(parseArguments).mockResolvedValue({
enabled: true,
allowedPaths: [],
networkAccess: false,
promptInteractive: false,
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
vi.mocked(loadCliConfig).mockResolvedValue(
@@ -877,6 +909,9 @@ describe('gemini.tsx main function kitty protocol', () => {
);
vi.mocked(parseArguments).mockResolvedValue({
enabled: true,
allowedPaths: [],
networkAccess: false,
promptInteractive: false,
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
vi.mocked(loadCliConfig).mockResolvedValue(
@@ -951,6 +986,9 @@ describe('gemini.tsx main function exit codes', () => {
}),
);
vi.mocked(parseArguments).mockResolvedValue({
enabled: true,
allowedPaths: [],
networkAccess: false,
promptInteractive: true,
} as unknown as CliArgs);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -967,10 +1005,12 @@ describe('gemini.tsx main function exit codes', () => {
it('should exit with 41 for auth failure during sandbox setup', async () => {
vi.stubEnv('SANDBOX', '');
vi.mocked(loadSandboxConfig).mockResolvedValue({
command: 'docker',
image: 'test-image',
});
vi.mocked(loadSandboxConfig).mockResolvedValue(
createMockSandboxConfig({
command: 'docker',
image: 'test-image',
}),
);
vi.mocked(loadCliConfig).mockResolvedValue(
createMockConfig({
refreshAuth: vi.fn().mockRejectedValue(new Error('Auth failed')),
@@ -1010,6 +1050,9 @@ describe('gemini.tsx main function exit codes', () => {
}),
);
vi.mocked(parseArguments).mockResolvedValue({
enabled: true,
allowedPaths: [],
networkAccess: false,
resume: 'invalid-session',
} as unknown as CliArgs);
@@ -1051,7 +1094,11 @@ describe('gemini.tsx main function exit codes', () => {
merged: { security: { auth: {} }, ui: {} },
}),
);
vi.mocked(parseArguments).mockResolvedValue({} as unknown as CliArgs);
vi.mocked(parseArguments).mockResolvedValue({
enabled: true,
allowedPaths: [],
networkAccess: false,
} as unknown as CliArgs);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(process.stdin as any).isTTY = true;
@@ -1086,7 +1133,11 @@ describe('gemini.tsx main function exit codes', () => {
merged: { security: { auth: { selectedType: undefined } }, ui: {} },
}),
);
vi.mocked(parseArguments).mockResolvedValue({} as unknown as CliArgs);
vi.mocked(parseArguments).mockResolvedValue({
enabled: true,
allowedPaths: [],
networkAccess: false,
} as unknown as CliArgs);
runNonInteractiveSpy.mockImplementation(() => Promise.resolve());
@@ -1156,7 +1207,12 @@ describe('project hooks loading based on trust', () => {
const configModule = await import('./config/config.js');
loadCliConfig = vi.mocked(configModule.loadCliConfig);
parseArguments = vi.mocked(configModule.parseArguments);
parseArguments.mockResolvedValue({ startupMessages: [] });
parseArguments.mockResolvedValue({
enabled: true,
allowedPaths: [],
networkAccess: false,
startupMessages: [],
});
const settingsModule = await import('./config/settings.js');
loadSettings = vi.mocked(settingsModule.loadSettings);
@@ -13,6 +13,7 @@ import {
ApprovalMode,
getShellConfiguration,
PolicyDecision,
NoopSandboxManager,
} from '@google/gemini-cli-core';
import { quote } from 'shell-quote';
import { createPartFromText } from '@google/genai';
@@ -77,7 +78,14 @@ describe('ShellProcessor', () => {
getTargetDir: vi.fn().mockReturnValue('/test/dir'),
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
getEnableInteractiveShell: vi.fn().mockReturnValue(false),
getShellExecutionConfig: vi.fn().mockReturnValue({}),
getShellExecutionConfig: vi.fn().mockReturnValue({
sandboxManager: new NoopSandboxManager(),
sanitizationConfig: {
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: [],
enableEnvironmentVariableRedaction: false,
},
}),
getPolicyEngine: vi.fn().mockReturnValue({
check: mockPolicyEngineCheck,
}),
+9 -2
View File
@@ -5,7 +5,7 @@
*/
import { vi } from 'vitest';
import type { Config } from '@google/gemini-cli-core';
import { type Config, NoopSandboxManager } from '@google/gemini-cli-core';
import type { LoadedSettings, Settings } from '../config/settings.js';
import { createTestMergedSettings } from '../config/settings.js';
@@ -128,7 +128,14 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getRetryFetchErrors: vi.fn().mockReturnValue(false),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
getShellToolInactivityTimeout: vi.fn().mockReturnValue(300000),
getShellExecutionConfig: vi.fn().mockReturnValue({}),
getShellExecutionConfig: vi.fn().mockReturnValue({
sandboxManager: new NoopSandboxManager(),
sanitizationConfig: {
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: [],
enableEnvironmentVariableRedaction: false,
},
}),
setShellExecutionConfig: vi.fn(),
getEnableToolOutputTruncation: vi.fn().mockReturnValue(true),
getTruncateToolOutputThreshold: vi.fn().mockReturnValue(1000),
+1
View File
@@ -1405,6 +1405,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
pager: settings.merged.tools.shell.pager,
showColor: settings.merged.tools.shell.showColor,
sanitizationConfig: config.sanitizationConfig,
sandboxManager: config.sandboxManager,
});
const { isFocused, hasReceivedFocusEvent } = useFocus();
@@ -16,6 +16,7 @@ import {
afterEach,
type Mock,
} from 'vitest';
import { NoopSandboxManager } from '@google/gemini-cli-core';
const mockIsBinary = vi.hoisted(() => vi.fn());
const mockShellExecutionService = vi.hoisted(() => vi.fn());
@@ -109,8 +110,14 @@ describe('useShellCommandProcessor', () => {
getShellExecutionConfig: () => ({
terminalHeight: 20,
terminalWidth: 80,
sandboxManager: new NoopSandboxManager(),
sanitizationConfig: {
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: [],
enableEnvironmentVariableRedaction: false,
},
}),
} as Config;
} as unknown as Config;
mockGeminiClient = { addHistory: vi.fn() } as unknown as GeminiClient;
vi.mocked(os.platform).mockReturnValue('linux');
+27 -26
View File
@@ -10,6 +10,7 @@ import os from 'node:os';
import fs from 'node:fs';
import { start_sandbox } from './sandbox.js';
import { FatalSandboxError, type SandboxConfig } from '@google/gemini-cli-core';
import { createMockSandboxConfig } from '@google/gemini-cli-test-utils';
import { EventEmitter } from 'node:events';
const { mockedHomedir, mockedGetContainerPath } = vi.hoisted(() => ({
@@ -137,10 +138,10 @@ describe('sandbox', () => {
describe('start_sandbox', () => {
it('should handle macOS seatbelt (sandbox-exec)', async () => {
vi.mocked(os.platform).mockReturnValue('darwin');
const config: SandboxConfig = {
const config: SandboxConfig = createMockSandboxConfig({
command: 'sandbox-exec',
image: 'some-image',
};
});
interface MockProcess extends EventEmitter {
stdout: EventEmitter;
@@ -173,19 +174,19 @@ describe('sandbox', () => {
it('should throw FatalSandboxError if seatbelt profile is missing', async () => {
vi.mocked(os.platform).mockReturnValue('darwin');
vi.mocked(fs.existsSync).mockReturnValue(false);
const config: SandboxConfig = {
const config: SandboxConfig = createMockSandboxConfig({
command: 'sandbox-exec',
image: 'some-image',
};
});
await expect(start_sandbox(config)).rejects.toThrow(FatalSandboxError);
});
it('should handle Docker execution', async () => {
const config: SandboxConfig = {
const config: SandboxConfig = createMockSandboxConfig({
command: 'docker',
image: 'gemini-cli-sandbox',
};
});
// Mock image check to return true (image exists)
interface MockProcessWithStdout extends EventEmitter {
@@ -231,10 +232,10 @@ describe('sandbox', () => {
});
it('should pull image if missing', async () => {
const config: SandboxConfig = {
const config: SandboxConfig = createMockSandboxConfig({
command: 'docker',
image: 'missing-image',
};
});
// 1. Image check fails
interface MockProcessWithStdout extends EventEmitter {
@@ -300,10 +301,10 @@ describe('sandbox', () => {
});
it('should throw if image pull fails', async () => {
const config: SandboxConfig = {
const config: SandboxConfig = createMockSandboxConfig({
command: 'docker',
image: 'missing-image',
};
});
// 1. Image check fails
interface MockProcessWithStdout extends EventEmitter {
@@ -338,10 +339,10 @@ describe('sandbox', () => {
});
it('should mount volumes correctly', async () => {
const config: SandboxConfig = {
const config: SandboxConfig = createMockSandboxConfig({
command: 'docker',
image: 'gemini-cli-sandbox',
};
});
process.env['SANDBOX_MOUNTS'] = '/host/path:/container/path:ro';
vi.mocked(fs.existsSync).mockReturnValue(true); // For mount path check
@@ -395,10 +396,10 @@ describe('sandbox', () => {
});
it('should pass through GOOGLE_GEMINI_BASE_URL and GOOGLE_VERTEX_BASE_URL', async () => {
const config: SandboxConfig = {
const config: SandboxConfig = createMockSandboxConfig({
command: 'docker',
image: 'gemini-cli-sandbox',
};
});
process.env['GOOGLE_GEMINI_BASE_URL'] = 'http://gemini.proxy';
process.env['GOOGLE_VERTEX_BASE_URL'] = 'http://vertex.proxy';
@@ -442,10 +443,10 @@ describe('sandbox', () => {
});
it('should handle user creation on Linux if needed', async () => {
const config: SandboxConfig = {
const config: SandboxConfig = createMockSandboxConfig({
command: 'docker',
image: 'gemini-cli-sandbox',
};
});
process.env['SANDBOX_SET_UID_GID'] = 'true';
vi.mocked(os.platform).mockReturnValue('linux');
vi.mocked(execSync).mockImplementation((cmd) => {
@@ -508,10 +509,10 @@ describe('sandbox', () => {
it('should run lxc exec with correct args for a running container', async () => {
process.env['TEST_LXC_LIST_OUTPUT'] = LXC_RUNNING;
const config: SandboxConfig = {
const config: SandboxConfig = createMockSandboxConfig({
command: 'lxc',
image: 'gemini-sandbox',
};
});
const mockSpawnProcess = new EventEmitter() as unknown as ReturnType<
typeof spawn
@@ -542,10 +543,10 @@ describe('sandbox', () => {
it('should throw FatalSandboxError if lxc list fails', async () => {
process.env['TEST_LXC_LIST_OUTPUT'] = 'throw';
const config: SandboxConfig = {
const config: SandboxConfig = createMockSandboxConfig({
command: 'lxc',
image: 'gemini-sandbox',
};
});
await expect(start_sandbox(config)).rejects.toThrow(
/Failed to query LXC container/,
@@ -554,20 +555,20 @@ describe('sandbox', () => {
it('should throw FatalSandboxError if container is not running', async () => {
process.env['TEST_LXC_LIST_OUTPUT'] = LXC_STOPPED;
const config: SandboxConfig = {
const config: SandboxConfig = createMockSandboxConfig({
command: 'lxc',
image: 'gemini-sandbox',
};
});
await expect(start_sandbox(config)).rejects.toThrow(/is not running/);
});
it('should throw FatalSandboxError if container is not found in list', async () => {
process.env['TEST_LXC_LIST_OUTPUT'] = '[]';
const config: SandboxConfig = {
const config: SandboxConfig = createMockSandboxConfig({
command: 'lxc',
image: 'gemini-sandbox',
};
});
await expect(start_sandbox(config)).rejects.toThrow(/not found/);
});
@@ -577,10 +578,10 @@ describe('sandbox', () => {
describe('gVisor (runsc)', () => {
it('should use docker with --runtime=runsc on Linux', async () => {
vi.mocked(os.platform).mockReturnValue('linux');
const config: SandboxConfig = {
const config: SandboxConfig = createMockSandboxConfig({
command: 'runsc',
image: 'gemini-cli-sandbox',
};
});
// Mock image check
interface MockProcessWithStdout extends EventEmitter {
+24
View File
@@ -211,12 +211,35 @@ export async function start_sandbox(
});
}
if (config.command === 'windows-native') {
debugLogger.log('using native windows sandboxing ...');
// process.argv is [node, script, ...args]
// We want to skip the first element (node) when calling spawn(process.execPath, ...)
const finalArgv = cliArgs.slice(1);
const child = spawn(process.execPath, finalArgv, {
stdio: 'inherit',
env: {
...process.env,
SANDBOX: 'windows-native',
},
});
return await new Promise((resolve, reject) => {
child.on('error', reject);
child.on('close', (code) => {
resolve(code ?? 1);
});
});
}
if (config.command === 'lxc') {
return await start_lxc_sandbox(config, nodeArgs, cliArgs);
}
// runsc uses docker with --runtime=runsc
const command = config.command === 'runsc' ? 'docker' : config.command;
if (!command) throw new FatalSandboxError('Sandbox command is required');
debugLogger.log(`hopping into sandbox (command: ${command}) ...`);
@@ -230,6 +253,7 @@ export async function start_sandbox(
const isCustomProjectSandbox = fs.existsSync(projectSandboxDockerfile);
const image = config.image;
if (!image) throw new FatalSandboxError('Sandbox image is required');
const workdir = path.resolve(process.cwd());
const containerWorkdir = getContainerPath(workdir);
+2 -1
View File
@@ -10,11 +10,12 @@
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "node ../../scripts/build_package.js",
"build": "node scripts/compile-windows-sandbox.js && node ../../scripts/build_package.js",
"lint": "eslint . --ext .ts,.tsx",
"format": "prettier --write .",
"test": "vitest run",
"test:ci": "vitest run",
"prepare": "node scripts/compile-windows-sandbox.js",
"typecheck": "tsc --noEmit"
},
"files": [
@@ -0,0 +1,79 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import fs from 'node:fs';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
/**
* Compiles the GeminiSandbox C# helper on Windows.
* This is used to provide native restricted token sandboxing.
*/
function compileWindowsSandbox() {
if (os.platform() !== 'win32') {
return;
}
const srcHelperPath = path.resolve(__dirname, '../src/services/scripts/GeminiSandbox.exe');
const distHelperPath = path.resolve(__dirname, '../dist/src/services/scripts/GeminiSandbox.exe');
const sourcePath = path.resolve(__dirname, '../src/services/scripts/GeminiSandbox.cs');
if (!fs.existsSync(sourcePath)) {
console.error(`Sandbox source not found at ${sourcePath}`);
return;
}
// Ensure directories exist
[srcHelperPath, distHelperPath].forEach(p => {
const dir = path.dirname(p);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
});
// Find csc.exe (C# Compiler) which is built into Windows .NET Framework
const systemRoot = process.env['SystemRoot'] || 'C:\\Windows';
const cscPaths = [
path.join(systemRoot, 'Microsoft.NET', 'Framework64', 'v4.0.30319', 'csc.exe'),
path.join(systemRoot, 'Microsoft.NET', 'Framework', 'v4.0.30319', 'csc.exe'),
];
const csc = cscPaths.find(p => fs.existsSync(p));
if (!csc) {
console.warn('Windows C# compiler (csc.exe) not found. Native sandboxing will attempt to compile on first run.');
return;
}
console.log(`Compiling native Windows sandbox helper...`);
// Compile to src
let result = spawnSync(csc, [`/out:${srcHelperPath}`, '/optimize', sourcePath], {
stdio: 'inherit',
});
if (result.status === 0) {
console.log('Successfully compiled GeminiSandbox.exe to src');
// Copy to dist if dist exists
const distDir = path.resolve(__dirname, '../dist');
if (fs.existsSync(distDir)) {
const distScriptsDir = path.dirname(distHelperPath);
if (!fs.existsSync(distScriptsDir)) {
fs.mkdirSync(distScriptsDir, { recursive: true });
}
fs.copyFileSync(srcHelperPath, distHelperPath);
console.log('Successfully copied GeminiSandbox.exe to dist');
}
} else {
console.error('Failed to compile Windows sandbox helper.');
}
}
compileWindowsSandbox();
@@ -7,6 +7,7 @@
import type { GeminiClient } from '../core/client.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import type { ToolRegistry } from '../tools/tool-registry.js';
import type { SandboxManager } from '../services/sandboxManager.js';
/**
* AgentLoopContext represents the execution-scoped view of the world for a single
@@ -24,4 +25,7 @@ export interface AgentLoopContext {
/** The client used to communicate with the LLM in this context. */
readonly geminiClient: GeminiClient;
/** The service used to prepare commands for sandboxed execution. */
readonly sandboxManager: SandboxManager;
}
+98 -10
View File
@@ -19,6 +19,7 @@ import {
type ConfigParameters,
type SandboxConfig,
} from './config.js';
import { createMockSandboxConfig } from '@google/gemini-cli-test-utils';
import { DEFAULT_MAX_ATTEMPTS } from '../utils/retry.js';
import { ExperimentFlags } from '../code_assist/experiments/flagNames.js';
import { debugLogger } from '../utils/debugLogger.js';
@@ -247,10 +248,10 @@ vi.mock('../code_assist/experiments/experiments.js');
describe('Server Config (config.ts)', () => {
const MODEL = DEFAULT_GEMINI_MODEL;
const SANDBOX: SandboxConfig = {
const SANDBOX: SandboxConfig = createMockSandboxConfig({
command: 'docker',
image: 'gemini-cli-sandbox',
};
});
const TARGET_DIR = '/path/to/target';
const DEBUG_MODE = false;
const QUESTION = 'test question';
@@ -1477,14 +1478,62 @@ describe('Server Config (config.ts)', () => {
expect(browserConfig.customConfig.sessionMode).toBe('persistent');
});
});
describe('Sandbox Configuration', () => {
it('should default sandbox settings when not provided', () => {
const config = new Config({
...baseParams,
sandbox: undefined,
});
expect(config.getSandboxEnabled()).toBe(false);
expect(config.getSandboxAllowedPaths()).toEqual([]);
expect(config.getSandboxNetworkAccess()).toBe(false);
});
it('should store provided sandbox settings', () => {
const sandbox: SandboxConfig = {
enabled: true,
allowedPaths: ['/tmp/foo', '/var/bar'],
networkAccess: true,
command: 'docker',
image: 'my-image',
};
const config = new Config({
...baseParams,
sandbox,
});
expect(config.getSandboxEnabled()).toBe(true);
expect(config.getSandboxAllowedPaths()).toEqual(['/tmp/foo', '/var/bar']);
expect(config.getSandboxNetworkAccess()).toBe(true);
expect(config.getSandbox()?.command).toBe('docker');
expect(config.getSandbox()?.image).toBe('my-image');
});
it('should partially override default sandbox settings', () => {
const config = new Config({
...baseParams,
sandbox: {
enabled: true,
allowedPaths: ['/only/this'],
networkAccess: false,
} as SandboxConfig,
});
expect(config.getSandboxEnabled()).toBe(true);
expect(config.getSandboxAllowedPaths()).toEqual(['/only/this']);
expect(config.getSandboxNetworkAccess()).toBe(false);
});
});
});
describe('GemmaModelRouterSettings', () => {
const MODEL = DEFAULT_GEMINI_MODEL;
const SANDBOX: SandboxConfig = {
const SANDBOX: SandboxConfig = createMockSandboxConfig({
command: 'docker',
image: 'gemini-cli-sandbox',
};
});
const TARGET_DIR = '/path/to/target';
const DEBUG_MODE = false;
const QUESTION = 'test question';
@@ -1861,10 +1910,10 @@ describe('isYoloModeDisabled', () => {
describe('BaseLlmClient Lifecycle', () => {
const MODEL = 'gemini-pro';
const SANDBOX: SandboxConfig = {
const SANDBOX: SandboxConfig = createMockSandboxConfig({
command: 'docker',
image: 'gemini-cli-sandbox',
};
});
const TARGET_DIR = '/path/to/target';
const DEBUG_MODE = false;
const QUESTION = 'test question';
@@ -1916,10 +1965,10 @@ describe('BaseLlmClient Lifecycle', () => {
describe('Generation Config Merging (HACK)', () => {
const MODEL = 'gemini-pro';
const SANDBOX: SandboxConfig = {
const SANDBOX: SandboxConfig = createMockSandboxConfig({
command: 'docker',
image: 'gemini-cli-sandbox',
};
});
const TARGET_DIR = '/path/to/target';
const DEBUG_MODE = false;
const QUESTION = 'test question';
@@ -2222,10 +2271,10 @@ describe('Config getHooks', () => {
describe('LocalLiteRtLmClient Lifecycle', () => {
const MODEL = 'gemini-pro';
const SANDBOX: SandboxConfig = {
const SANDBOX: SandboxConfig = createMockSandboxConfig({
command: 'docker',
image: 'gemini-cli-sandbox',
};
});
const TARGET_DIR = '/path/to/target';
const DEBUG_MODE = false;
const QUESTION = 'test question';
@@ -2540,6 +2589,9 @@ describe('Config Quota & Preview Model Access', () => {
usageStatisticsEnabled: false,
embeddingModel: 'gemini-embedding',
sandbox: {
enabled: true,
allowedPaths: [],
networkAccess: false,
command: 'docker',
image: 'gemini-cli-sandbox',
},
@@ -3175,3 +3227,39 @@ describe('Model Persistence Bug Fix (#19864)', () => {
expect(config.getModel()).toBe(PREVIEW_GEMINI_3_1_MODEL);
});
});
describe('ConfigSchema validation', () => {
it('should validate a valid sandbox config', async () => {
const validConfig = {
sandbox: {
enabled: true,
allowedPaths: ['/tmp'],
networkAccess: false,
command: 'docker',
image: 'node:20',
},
};
const { ConfigSchema } = await import('./config.js');
const result = ConfigSchema.safeParse(validConfig);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.sandbox?.enabled).toBe(true);
}
});
it('should apply defaults in ConfigSchema', async () => {
const minimalConfig = {
sandbox: {},
};
const { ConfigSchema } = await import('./config.js');
const result = ConfigSchema.safeParse(minimalConfig);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.sandbox?.enabled).toBe(false);
expect(result.data.sandbox?.allowedPaths).toEqual([]);
expect(result.data.sandbox?.networkAccess).toBe(false);
}
});
});
+80 -6
View File
@@ -6,8 +6,10 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { inspect } from 'node:util';
import process from 'node:process';
import { z } from 'zod';
import {
AuthType,
createContentGenerator,
@@ -40,6 +42,11 @@ import { LocalLiteRtLmClient } from '../core/localLiteRtLmClient.js';
import type { HookDefinition, HookEventName } from '../hooks/types.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { GitService } from '../services/gitService.js';
import {
NoopSandboxManager,
type SandboxManager,
} from '../services/sandboxManager.js';
import { WindowsSandboxManager } from '../services/windowsSandboxManager.js';
import {
initializeTelemetry,
DEFAULT_TELEMETRY_TARGET,
@@ -69,6 +76,7 @@ import {
StandardFileSystemService,
type FileSystemService,
} from '../services/fileSystemService.js';
import { SandboxedFileSystemService } from '../services/sandboxedFileSystemService.js';
import {
TrackerCreateTaskTool,
TrackerUpdateTaskTool,
@@ -96,7 +104,6 @@ import type {
import { ModelAvailabilityService } from '../availability/modelAvailabilityService.js';
import { ModelRouterService } from '../routing/modelRouterService.js';
import { OutputFormat } from '../output/types.js';
//import { type AgentLoopContext } from './agent-loop-context.js';
import {
ModelConfigService,
type ModelConfig,
@@ -451,10 +458,27 @@ export enum AuthProviderType {
}
export interface SandboxConfig {
command: 'docker' | 'podman' | 'sandbox-exec' | 'runsc' | 'lxc';
image: string;
enabled: boolean;
allowedPaths: string[];
networkAccess: boolean;
command?: 'docker' | 'podman' | 'sandbox-exec' | 'runsc' | 'lxc' | 'windows-native';
image?: string;
}
export const ConfigSchema = z.object({
sandbox: z
.object({
enabled: z.boolean().default(false),
allowedPaths: z.array(z.string()).default([]),
networkAccess: z.boolean().default(false),
command: z
.enum(['docker', 'podman', 'sandbox-exec', 'runsc', 'lxc', 'windows-native'])
.optional(),
image: z.string().optional(),
})
.optional(),
});
/**
* Callbacks for checking MCP server enablement status.
* These callbacks are provided by the CLI package to bridge
@@ -652,6 +676,7 @@ export class Config implements McpContext, AgentLoopContext {
private readonly telemetrySettings: TelemetrySettings;
private readonly usageStatisticsEnabled: boolean;
private _geminiClient!: GeminiClient;
private readonly _sandboxManager: SandboxManager;
private baseLlmClient!: BaseLlmClient;
private localLiteRtLmClient?: LocalLiteRtLmClient;
private modelRouterService: ModelRouterService;
@@ -817,8 +842,19 @@ export class Config implements McpContext, AgentLoopContext {
this.approvedPlanPath = undefined;
this.embeddingModel =
params.embeddingModel ?? DEFAULT_GEMINI_EMBEDDING_MODEL;
this.fileSystemService = new StandardFileSystemService();
this.sandbox = params.sandbox;
this.sandbox = params.sandbox
? {
enabled: params.sandbox.enabled ?? false,
allowedPaths: params.sandbox.allowedPaths ?? [],
networkAccess: params.sandbox.networkAccess ?? false,
command: params.sandbox.command,
image: params.sandbox.image,
}
: {
enabled: false,
allowedPaths: [],
networkAccess: false,
};
this.targetDir = path.resolve(params.targetDir);
this.folderTrust = params.folderTrust ?? false;
this.workspaceContext = new WorkspaceContext(this.targetDir, []);
@@ -942,17 +978,37 @@ export class Config implements McpContext, AgentLoopContext {
this.useAlternateBuffer = params.useAlternateBuffer ?? false;
this.enableInteractiveShell = params.enableInteractiveShell ?? false;
this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? true;
if (
os.platform() === 'win32' &&
(this.sandbox?.enabled || this.sandbox?.command === 'windows-native')
) {
this._sandboxManager = new WindowsSandboxManager();
} else {
this._sandboxManager = new NoopSandboxManager();
}
if (this.sandbox?.enabled && this._sandboxManager) {
this.fileSystemService = new SandboxedFileSystemService(
this._sandboxManager,
this.cwd,
);
} else {
this.fileSystemService = new StandardFileSystemService();
}
this.shellExecutionConfig = {
terminalWidth: params.shellExecutionConfig?.terminalWidth ?? 80,
terminalHeight: params.shellExecutionConfig?.terminalHeight ?? 24,
showColor: params.shellExecutionConfig?.showColor ?? false,
pager: params.shellExecutionConfig?.pager ?? 'cat',
sanitizationConfig: this.sanitizationConfig,
sandboxManager: this._sandboxManager,
sandboxConfig: this.sandbox,
};
this.truncateToolOutputThreshold =
params.truncateToolOutputThreshold ??
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD;
// // TODO(joshualitt): Re-evaluate the todo tool for 3 family.
this.useWriteTodos = isPreviewModel(this.model)
? false
: (params.useWriteTodos ?? true);
@@ -1365,6 +1421,10 @@ export class Config implements McpContext, AgentLoopContext {
return this._geminiClient;
}
get sandboxManager(): SandboxManager {
return this._sandboxManager;
}
getSessionId(): string {
return this.promptId;
}
@@ -1603,6 +1663,18 @@ export class Config implements McpContext, AgentLoopContext {
return this.sandbox;
}
getSandboxEnabled(): boolean {
return this.sandbox?.enabled ?? false;
}
getSandboxAllowedPaths(): string[] {
return this.sandbox?.allowedPaths ?? [];
}
getSandboxNetworkAccess(): boolean {
return this.sandbox?.networkAccess ?? false;
}
isRestrictiveSandbox(): boolean {
const sandboxConfig = this.getSandbox();
const seatbeltProfile = process.env['SEATBELT_PROFILE'];
@@ -2686,6 +2758,8 @@ export class Config implements McpContext, AgentLoopContext {
sanitizationConfig:
config.sanitizationConfig ??
this.shellExecutionConfig.sanitizationConfig,
sandboxManager:
config.sandboxManager ?? this.shellExecutionConfig.sandboxManager,
};
}
getScreenReader(): boolean {
@@ -0,0 +1,65 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { Config } from './config.js';
import { NoopSandboxManager } from '../services/sandboxManager.js';
// Minimal mocks for Config dependencies to allow instantiation
vi.mock('../core/client.js');
vi.mock('../core/contentGenerator.js');
vi.mock('../telemetry/index.js');
vi.mock('../core/tokenLimits.js');
vi.mock('../services/fileDiscoveryService.js');
vi.mock('../services/gitService.js');
vi.mock('../services/trackerService.js');
vi.mock('../confirmation-bus/message-bus.js', () => ({
MessageBus: vi.fn(),
}));
vi.mock('../policy/policy-engine.js', () => ({
PolicyEngine: vi.fn().mockImplementation(() => ({
getExcludedTools: vi.fn().mockReturnValue(new Set()),
})),
}));
vi.mock('../skills/skillManager.js', () => ({
SkillManager: vi.fn().mockImplementation(() => ({
setAdminSettings: vi.fn(),
})),
}));
vi.mock('../agents/registry.js', () => ({
AgentRegistry: vi.fn().mockImplementation(() => ({
initialize: vi.fn(),
})),
}));
vi.mock('../agents/acknowledgedAgents.js', () => ({
AcknowledgedAgentsService: vi.fn(),
}));
vi.mock('../services/modelConfigService.js', () => ({
ModelConfigService: vi.fn(),
}));
vi.mock('./models.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./models.js')>();
return {
...actual,
isPreviewModel: vi.fn().mockReturnValue(false),
resolveModel: vi.fn().mockReturnValue('test-model'),
};
});
describe('Sandbox Integration', () => {
it('should have a NoopSandboxManager by default in Config', () => {
const config = new Config({
sessionId: 'test-session',
targetDir: '.',
model: 'test-model',
cwd: '.',
debugMode: false,
});
expect(config.sandboxManager).toBeDefined();
expect(config.sandboxManager).toBeInstanceOf(NoopSandboxManager);
});
});
@@ -34,6 +34,7 @@ import {
GeminiCliOperation,
} from '../index.js';
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
import { NoopSandboxManager } from '../services/sandboxManager.js';
import {
MockModifiableTool,
MockTool,
@@ -274,6 +275,7 @@ function createMockConfig(overrides: Partial<Config> = {}): Config {
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: [],
},
sandboxManager: new NoopSandboxManager(),
}),
storage: {
getProjectTempDir: () => '/tmp',
@@ -1201,6 +1203,7 @@ describe('CoreToolScheduler request queueing', () => {
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: [],
},
sandboxManager: new NoopSandboxManager(),
}),
isInteractive: () => false,
});
@@ -1310,6 +1313,7 @@ describe('CoreToolScheduler request queueing', () => {
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: [],
},
sandboxManager: new NoopSandboxManager(),
}),
getToolRegistry: () => toolRegistry,
getHookSystem: () => undefined,
+1
View File
@@ -145,6 +145,7 @@ export * from './ide/types.js';
// Export Shell Execution Service
export * from './services/shellExecutionService.js';
export * from './services/sandboxManager.js';
// Export base tool definitions
export * from './tools/tools.js';
@@ -0,0 +1,111 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { NoopSandboxManager } from './sandboxManager.js';
describe('NoopSandboxManager', () => {
const sandboxManager = new NoopSandboxManager();
it('should pass through the command and arguments unchanged', async () => {
const req = {
command: 'ls',
args: ['-la'],
cwd: '/tmp',
env: { PATH: '/usr/bin' },
};
const result = await sandboxManager.prepareCommand(req);
expect(result.program).toBe('ls');
expect(result.args).toEqual(['-la']);
});
it('should sanitize the environment variables', async () => {
const req = {
command: 'echo',
args: ['hello'],
cwd: '/tmp',
env: {
PATH: '/usr/bin',
GITHUB_TOKEN: 'ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
MY_SECRET: 'super-secret',
SAFE_VAR: 'is-safe',
},
};
const result = await sandboxManager.prepareCommand(req);
expect(result.env['PATH']).toBe('/usr/bin');
expect(result.env['SAFE_VAR']).toBe('is-safe');
expect(result.env['GITHUB_TOKEN']).toBeUndefined();
expect(result.env['MY_SECRET']).toBeUndefined();
});
it('should force environment variable redaction even if not requested in config', async () => {
const req = {
command: 'echo',
args: ['hello'],
cwd: '/tmp',
env: {
API_KEY: 'sensitive-key',
},
config: {
sanitizationConfig: {
enableEnvironmentVariableRedaction: false,
},
},
};
const result = await sandboxManager.prepareCommand(req);
expect(result.env['API_KEY']).toBeUndefined();
});
it('should respect allowedEnvironmentVariables in config', async () => {
const req = {
command: 'echo',
args: ['hello'],
cwd: '/tmp',
env: {
MY_TOKEN: 'secret-token',
OTHER_SECRET: 'another-secret',
},
config: {
sanitizationConfig: {
allowedEnvironmentVariables: ['MY_TOKEN'],
},
},
};
const result = await sandboxManager.prepareCommand(req);
expect(result.env['MY_TOKEN']).toBe('secret-token');
expect(result.env['OTHER_SECRET']).toBeUndefined();
});
it('should respect blockedEnvironmentVariables in config', async () => {
const req = {
command: 'echo',
args: ['hello'],
cwd: '/tmp',
env: {
SAFE_VAR: 'safe-value',
BLOCKED_VAR: 'blocked-value',
},
config: {
sanitizationConfig: {
blockedEnvironmentVariables: ['BLOCKED_VAR'],
},
},
};
const result = await sandboxManager.prepareCommand(req);
expect(result.env['SAFE_VAR']).toBe('safe-value');
expect(result.env['BLOCKED_VAR']).toBeUndefined();
});
});
@@ -0,0 +1,82 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
sanitizeEnvironment,
type EnvironmentSanitizationConfig,
} from './environmentSanitization.js';
/**
* Request for preparing a command to run in a sandbox.
*/
export interface SandboxRequest {
/** The program to execute. */
command: string;
/** Arguments for the program. */
args: string[];
/** The working directory. */
cwd: string;
/** Environment variables to be passed to the program. */
env: NodeJS.ProcessEnv;
/** Optional sandbox-specific configuration. */
config?: {
sanitizationConfig?: Partial<EnvironmentSanitizationConfig>;
allowedPaths?: string[];
networkAccess?: boolean;
};
}
/**
* A command that has been prepared for sandboxed execution.
*/
export interface SandboxedCommand {
/** The program or wrapper to execute. */
program: string;
/** Final arguments for the program. */
args: string[];
/** Sanitized environment variables. */
env: NodeJS.ProcessEnv;
}
/**
* Interface for a service that prepares commands for sandboxed execution.
*/
export interface SandboxManager {
/**
* Prepares a command to run in a sandbox, including environment sanitization.
*/
prepareCommand(req: SandboxRequest): Promise<SandboxedCommand>;
}
/**
* A no-op implementation of SandboxManager that silently passes commands
* through while applying environment sanitization.
*/
export class NoopSandboxManager implements SandboxManager {
/**
* Prepares a command by sanitizing the environment and passing through
* the original program and arguments.
*/
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
const sanitizationConfig: EnvironmentSanitizationConfig = {
allowedEnvironmentVariables:
req.config?.sanitizationConfig?.allowedEnvironmentVariables ?? [],
blockedEnvironmentVariables:
req.config?.sanitizationConfig?.blockedEnvironmentVariables ?? [],
enableEnvironmentVariableRedaction:
req.config?.sanitizationConfig?.enableEnvironmentVariableRedaction ??
true,
};
const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig);
return {
program: req.command,
args: req.args,
env: sanitizedEnv,
};
}
}
@@ -0,0 +1,86 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { spawn } from 'node:child_process';
import { type FileSystemService } from './fileSystemService.js';
import { type SandboxManager } from './sandboxManager.js';
/**
* A FileSystemService implementation that performs operations through a sandbox.
*/
export class SandboxedFileSystemService implements FileSystemService {
constructor(
private sandboxManager: SandboxManager,
private cwd: string,
) {}
async readTextFile(filePath: string): Promise<string> {
const prepared = await this.sandboxManager.prepareCommand({
command: '__read',
args: [filePath],
cwd: this.cwd,
env: process.env,
});
return new Promise((resolve, reject) => {
const child = spawn(prepared.program, prepared.args, {
cwd: this.cwd,
env: prepared.env,
});
let output = '';
let error = '';
child.stdout?.on('data', (data) => {
output += data.toString();
});
child.stderr?.on('data', (data) => {
error += data.toString();
});
child.on('close', (code) => {
if (code === 0) {
resolve(output);
} else {
reject(new Error(`Sandbox Error: Command failed with exit code ${code}. ${error ? 'Details: ' + error : ''}`));
}
});
});
}
async writeTextFile(filePath: string, content: string): Promise<void> {
const prepared = await this.sandboxManager.prepareCommand({
command: '__write',
args: [filePath],
cwd: this.cwd,
env: process.env,
});
return new Promise((resolve, reject) => {
const child = spawn(prepared.program, prepared.args, {
cwd: this.cwd,
env: prepared.env,
});
child.stdin?.write(content);
child.stdin?.end();
let error = '';
child.stderr?.on('data', (data) => {
error += data.toString();
});
child.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Sandbox Error: Command failed with exit code ${code}. ${error ? 'Details: ' + error : ''}`));
}
});
});
}
}
@@ -0,0 +1,295 @@
using System;
using System.Runtime.InteropServices;
using System.Collections.Generic;
using System.Diagnostics;
using System.Security.Principal;
using System.IO;
public class GeminiSandbox {
[StructLayout(LayoutKind.Sequential)]
public struct STARTUPINFO {
public uint cb;
public string lpReserved;
public string lpDesktop;
public string lpTitle;
public uint dwX;
public uint dwY;
public uint dwXSize;
public uint dwYSize;
public uint dwXCountChars;
public uint dwYCountChars;
public uint dwFillAttribute;
public uint dwFlags;
public ushort wShowWindow;
public ushort cbReserved2;
public IntPtr lpReserved2;
public IntPtr hStdInput;
public IntPtr hStdOutput;
public IntPtr hStdError;
}
[StructLayout(LayoutKind.Sequential)]
public struct PROCESS_INFORMATION {
public IntPtr hProcess;
public IntPtr hThread;
public uint dwProcessId;
public uint dwThreadId;
}
[StructLayout(LayoutKind.Sequential)]
public struct JOBOBJECT_BASIC_LIMIT_INFORMATION {
public Int64 PerProcessUserTimeLimit;
public Int64 PerJobUserTimeLimit;
public uint LimitFlags;
public UIntPtr MinimumWorkingSetSize;
public UIntPtr MaximumWorkingSetSize;
public uint ActiveProcessLimit;
public UIntPtr Affinity;
public uint PriorityClass;
public uint SchedulingClass;
}
[StructLayout(LayoutKind.Sequential)]
public struct IO_COUNTERS {
public ulong ReadOperationCount;
public ulong WriteOperationCount;
public ulong OtherOperationCount;
public ulong ReadTransferCount;
public ulong WriteTransferCount;
public ulong OtherTransferCount;
}
[StructLayout(LayoutKind.Sequential)]
public struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION {
public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
public IO_COUNTERS IoInfo;
public UIntPtr ProcessMemoryLimit;
public UIntPtr JobMemoryLimit;
public UIntPtr PeakProcessMemoryUsed;
public UIntPtr PeakJobMemoryUsed;
}
[StructLayout(LayoutKind.Sequential)]
public struct SID_AND_ATTRIBUTES {
public IntPtr Sid;
public uint Attributes;
}
[StructLayout(LayoutKind.Sequential)]
public struct TOKEN_MANDATORY_LABEL {
public SID_AND_ATTRIBUTES Label;
}
public enum JobObjectInfoClass {
ExtendedLimitInformation = 9
}
[DllImport("kernel32.dll", SetLastError = true)]
public static extern IntPtr GetCurrentProcess();
[DllImport("advapi32.dll", SetLastError = true)]
public static extern bool OpenProcessToken(IntPtr ProcessHandle, uint DesiredAccess, out IntPtr TokenHandle);
[DllImport("advapi32.dll", SetLastError = true)]
public static extern bool CreateRestrictedToken(IntPtr ExistingTokenHandle, uint Flags, uint DisableSidCount, IntPtr SidsToDisable, uint DeletePrivilegeCount, IntPtr PrivilegesToDelete, uint RestrictedSidCount, IntPtr SidsToRestrict, out IntPtr NewTokenHandle);
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
public static extern bool CreateProcessAsUser(IntPtr hToken, string lpApplicationName, string lpCommandLine, IntPtr lpProcessAttributes, IntPtr lpThreadAttributes, bool bInheritHandles, uint dwCreationFlags, IntPtr lpEnvironment, string lpCurrentDirectory, ref STARTUPINFO lpStartupInfo, out PROCESS_INFORMATION lpProcessInformation);
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
public static extern IntPtr CreateJobObject(IntPtr lpJobAttributes, string lpName);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool SetInformationJobObject(IntPtr hJob, JobObjectInfoClass JobObjectInfoClass, IntPtr lpJobObjectInfo, uint cbJobObjectInfoLength);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern uint ResumeThread(IntPtr hThread);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool GetExitCodeProcess(IntPtr hProcess, out uint lpExitCode);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool CloseHandle(IntPtr hObject);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern IntPtr GetStdHandle(int nStdHandle);
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
public static extern bool ConvertStringSidToSid(string StringSid, out IntPtr Sid);
[DllImport("advapi32.dll", SetLastError = true)]
public static extern bool SetTokenInformation(IntPtr TokenHandle, int TokenInformationClass, IntPtr TokenInformation, uint TokenInformationLength);
public const uint TOKEN_DUPLICATE = 0x0002;
public const uint TOKEN_QUERY = 0x0008;
public const uint TOKEN_ASSIGN_PRIMARY = 0x0001;
public const uint TOKEN_ADJUST_DEFAULT = 0x0080;
public const uint DISABLE_MAX_PRIVILEGE = 0x1;
public const uint CREATE_SUSPENDED = 0x00000004;
public const uint CREATE_UNICODE_ENVIRONMENT = 0x00000400;
public const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000;
public const uint STARTF_USESTDHANDLES = 0x00000100;
public const int TokenIntegrityLevel = 25;
public const uint SE_GROUP_INTEGRITY = 0x00000020;
public const uint INFINITE = 0xFFFFFFFF;
static int Main(string[] args) {
if (args.Length < 3) {
Console.WriteLine("Usage: GeminiSandbox.exe <network:0|1> <cwd> <command> [args...]");
Console.WriteLine("Internal commands: __read <path>, __write <path>");
return 1;
}
bool networkAccess = args[0] == "1";
string cwd = args[1];
string command = args[2];
// 1. Setup Token
IntPtr hCurrentProcess = GetCurrentProcess();
IntPtr hToken;
if (!OpenProcessToken(hCurrentProcess, TOKEN_DUPLICATE | TOKEN_QUERY | TOKEN_ASSIGN_PRIMARY | TOKEN_ADJUST_DEFAULT, out hToken)) {
Console.Error.WriteLine("Failed to open process token");
return 1;
}
IntPtr hRestrictedToken;
IntPtr pSidsToDisable = IntPtr.Zero;
uint sidCount = 0;
IntPtr pSidsToRestrict = IntPtr.Zero;
uint restrictCount = 0;
// "networkAccess == false" implies Strict Sandbox Level 1.
// In Strict mode, we strip the Network SID and apply the Restricted Code SID.
// This blocks network access and restricts file reads, but requires cmd.exe.
if (!networkAccess) {
IntPtr networkSid;
if (ConvertStringSidToSid("S-1-5-2", out networkSid)) {
sidCount = 1;
int saaSize = Marshal.SizeOf(typeof(SID_AND_ATTRIBUTES));
pSidsToDisable = Marshal.AllocHGlobal(saaSize);
SID_AND_ATTRIBUTES saa = new SID_AND_ATTRIBUTES();
saa.Sid = networkSid;
saa.Attributes = 0;
Marshal.StructureToPtr(saa, pSidsToDisable, false);
}
IntPtr restrictedSid;
// S-1-5-12 is Restricted Code SID
if (ConvertStringSidToSid("S-1-5-12", out restrictedSid)) {
restrictCount = 1;
int saaSize = Marshal.SizeOf(typeof(SID_AND_ATTRIBUTES));
pSidsToRestrict = Marshal.AllocHGlobal(saaSize);
SID_AND_ATTRIBUTES saa = new SID_AND_ATTRIBUTES();
saa.Sid = restrictedSid;
saa.Attributes = 0;
Marshal.StructureToPtr(saa, pSidsToRestrict, false);
}
}
// If networkAccess == true, we are in Elevated mode (Level 2).
// We only strip privileges (DISABLE_MAX_PRIVILEGE), allowing network and powershell.
if (!CreateRestrictedToken(hToken, DISABLE_MAX_PRIVILEGE, sidCount, pSidsToDisable, 0, IntPtr.Zero, restrictCount, pSidsToRestrict, out hRestrictedToken)) {
Console.Error.WriteLine("Failed to create restricted token");
return 1;
}
// 2. Set Integrity Level to Low
IntPtr lowIntegritySid;
if (ConvertStringSidToSid("S-1-16-4096", out lowIntegritySid)) {
TOKEN_MANDATORY_LABEL tml = new TOKEN_MANDATORY_LABEL();
tml.Label.Sid = lowIntegritySid;
tml.Label.Attributes = SE_GROUP_INTEGRITY;
int tmlSize = Marshal.SizeOf(tml);
IntPtr pTml = Marshal.AllocHGlobal(tmlSize);
Marshal.StructureToPtr(tml, pTml, false);
SetTokenInformation(hRestrictedToken, TokenIntegrityLevel, pTml, (uint)tmlSize);
Marshal.FreeHGlobal(pTml);
}
// 3. Handle Internal Commands or External Process
if (command == "__read") {
string path = args[3];
return RunInImpersonation(hRestrictedToken, () => {
try {
Console.Write(File.ReadAllText(path));
return 0;
} catch (Exception e) {
Console.Error.WriteLine(e.Message);
return 1;
}
});
} else if (command == "__write") {
string path = args[3];
return RunInImpersonation(hRestrictedToken, () => {
try {
using (StreamReader reader = new StreamReader(Console.OpenStandardInput()))
using (StreamWriter writer = new StreamWriter(File.Create(path))) {
writer.Write(reader.ReadToEnd());
}
return 0;
} catch (Exception e) {
Console.Error.WriteLine(e.Message);
return 1;
}
});
}
// 4. Setup Job Object for external process
IntPtr hJob = CreateJobObject(IntPtr.Zero, null);
if (hJob != IntPtr.Zero) {
JOBOBJECT_EXTENDED_LIMIT_INFORMATION limitInfo = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION();
limitInfo.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
int limitSize = Marshal.SizeOf(limitInfo);
IntPtr pLimit = Marshal.AllocHGlobal(limitSize);
Marshal.StructureToPtr(limitInfo, pLimit, false);
SetInformationJobObject(hJob, JobObjectInfoClass.ExtendedLimitInformation, pLimit, (uint)limitSize);
Marshal.FreeHGlobal(pLimit);
}
// 5. Launch Process
STARTUPINFO si = new STARTUPINFO();
si.cb = (uint)Marshal.SizeOf(si);
si.dwFlags = STARTF_USESTDHANDLES;
si.hStdInput = GetStdHandle(-10);
si.hStdOutput = GetStdHandle(-11);
si.hStdError = GetStdHandle(-12);
string commandLine = string.Join(" ", args, 2, args.Length - 2);
PROCESS_INFORMATION pi;
if (!CreateProcessAsUser(hRestrictedToken, null, commandLine, IntPtr.Zero, IntPtr.Zero, true, CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT, IntPtr.Zero, cwd, ref si, out pi)) {
Console.Error.WriteLine("Failed to create process. Error: " + Marshal.GetLastWin32Error());
return 1;
}
if (hJob != IntPtr.Zero) {
AssignProcessToJobObject(hJob, pi.hProcess);
}
ResumeThread(pi.hThread);
WaitForSingleObject(pi.hProcess, INFINITE);
uint exitCode = 0;
GetExitCodeProcess(pi.hProcess, out exitCode);
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
CloseHandle(hRestrictedToken);
CloseHandle(hToken);
if (hJob != IntPtr.Zero) CloseHandle(hJob);
return (int)exitCode;
}
private static int RunInImpersonation(IntPtr hToken, Func<int> action) {
WindowsIdentity.Impersonate(hToken);
int result = action();
return result;
}
}
@@ -21,6 +21,7 @@ import {
type ShellOutputEvent,
type ShellExecutionConfig,
} from './shellExecutionService.js';
import { NoopSandboxManager } from './sandboxManager.js';
import type { AnsiOutput, AnsiToken } from '../utils/terminalSerializer.js';
// Hoisted Mocks
@@ -103,6 +104,7 @@ const shellExecutionConfig: ShellExecutionConfig = {
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: [],
},
sandboxManager: new NoopSandboxManager(),
};
const createMockSerializeTerminalToObjectReturnValue = (
@@ -578,6 +580,7 @@ describe('ShellExecutionService', () => {
new AbortController().signal,
true,
{
...shellExecutionConfig,
sanitizationConfig: {
enableEnvironmentVariableRedaction: true,
allowedEnvironmentVariables: [],
@@ -1219,6 +1222,7 @@ describe('ShellExecutionService child_process fallback', () => {
abortController.signal,
true,
{
...shellExecutionConfig,
sanitizationConfig: {
enableEnvironmentVariableRedaction: true,
allowedEnvironmentVariables: [],
@@ -1431,6 +1435,7 @@ describe('ShellExecutionService execution method selection', () => {
abortController.signal,
false, // shouldUseNodePty
{
...shellExecutionConfig,
sanitizationConfig: {
enableEnvironmentVariableRedaction: true,
allowedEnvironmentVariables: [],
@@ -1578,6 +1583,7 @@ describe('ShellExecutionService environment variables', () => {
new AbortController().signal,
true,
{
...shellExecutionConfig,
sanitizationConfig: {
enableEnvironmentVariableRedaction: false,
allowedEnvironmentVariables: [],
@@ -1637,6 +1643,7 @@ describe('ShellExecutionService environment variables', () => {
new AbortController().signal,
true,
{
...shellExecutionConfig,
sanitizationConfig: {
enableEnvironmentVariableRedaction: false,
allowedEnvironmentVariables: [],
@@ -1703,4 +1710,56 @@ describe('ShellExecutionService environment variables', () => {
mockChildProcess.emit('close', 0, null);
await new Promise(process.nextTick);
});
it('should call prepareCommand on sandboxManager when provided', async () => {
const mockSandboxManager = {
prepareCommand: vi.fn().mockResolvedValue({
program: 'sandboxed-bash',
args: ['-c', 'ls'],
env: { SANDBOXED: 'true' },
}),
};
const configWithSandbox: ShellExecutionConfig = {
...shellExecutionConfig,
sandboxManager: mockSandboxManager,
};
mockResolveExecutable.mockResolvedValue('/bin/bash/resolved');
const mockChild = new EventEmitter() as unknown as ChildProcess;
mockChild.stdout = new EventEmitter() as unknown as Readable;
mockChild.stderr = new EventEmitter() as unknown as Readable;
Object.assign(mockChild, { pid: 123 });
mockCpSpawn.mockReturnValue(mockChild);
const handle = await ShellExecutionService.execute(
'ls',
'/test/cwd',
() => {},
new AbortController().signal,
false, // child_process path
configWithSandbox,
);
expect(mockResolveExecutable).toHaveBeenCalledWith(expect.any(String));
expect(mockSandboxManager.prepareCommand).toHaveBeenCalledWith(
expect.objectContaining({
command: '/bin/bash/resolved',
args: expect.arrayContaining([expect.stringContaining('ls')]),
cwd: '/test/cwd',
}),
);
expect(mockCpSpawn).toHaveBeenCalledWith(
'sandboxed-bash',
['-c', 'ls'],
expect.objectContaining({
env: expect.objectContaining({ SANDBOXED: 'true' }),
}),
);
// Clean up
mockChild.emit('exit', 0, null);
mockChild.emit('close', 0, null);
await handle.result;
});
});
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -22,10 +22,9 @@ import {
serializeTerminalToObject,
type AnsiOutput,
} from '../utils/terminalSerializer.js';
import {
sanitizeEnvironment,
type EnvironmentSanitizationConfig,
} from './environmentSanitization.js';
import { type EnvironmentSanitizationConfig } from './environmentSanitization.js';
import { type SandboxManager } from './sandboxManager.js';
import type { SandboxConfig } from '../config/config.js';
import { killProcessGroup } from '../utils/process-utils.js';
const { Terminal } = pkg;
@@ -102,6 +101,8 @@ export interface ShellExecutionConfig {
defaultFg?: string;
defaultBg?: string;
sanitizationConfig: EnvironmentSanitizationConfig;
sandboxManager: SandboxManager;
sandboxConfig?: SandboxConfig;
// Used for testing
disableDynamicLineTrimming?: boolean;
scrollback?: number;
@@ -251,7 +252,7 @@ export class ShellExecutionService {
cwd,
onOutputEvent,
abortSignal,
shellExecutionConfig.sanitizationConfig,
shellExecutionConfig,
);
}
@@ -292,33 +293,87 @@ export class ShellExecutionService {
}
}
private static childProcessFallback(
private static async prepareExecution(
executable: string,
args: string[],
cwd: string,
env: NodeJS.ProcessEnv,
shellExecutionConfig: ShellExecutionConfig,
): Promise<{ program: string; args: string[]; env: NodeJS.ProcessEnv }> {
const resolvedExecutable =
(await resolveExecutable(executable)) ?? executable;
return shellExecutionConfig.sandboxManager.prepareCommand({
command: resolvedExecutable,
args,
cwd,
env,
config: {
sanitizationConfig: shellExecutionConfig.sanitizationConfig,
allowedPaths: shellExecutionConfig.sandboxConfig?.allowedPaths,
networkAccess: shellExecutionConfig.sandboxConfig?.networkAccess,
},
});
}
private static async childProcessFallback(
commandToExecute: string,
cwd: string,
onOutputEvent: (event: ShellOutputEvent) => void,
abortSignal: AbortSignal,
sanitizationConfig: EnvironmentSanitizationConfig,
): ShellExecutionHandle {
shellExecutionConfig: ShellExecutionConfig,
): Promise<ShellExecutionHandle> {
try {
const isWindows = os.platform() === 'win32';
const { executable, argsPrefix, shell } = getShellConfiguration();
const guardedCommand = ensurePromptvarsDisabled(commandToExecute, shell);
const spawnArgs = [...argsPrefix, guardedCommand];
// In Strict Sandbox mode (networkAccess = false), PowerShell will fail to load
// its required system DLLs due to the Restricted Code SID.
// We must fallback to cmd.exe for strict sandboxing.
const isStrictSandbox =
shellExecutionConfig.sandboxConfig?.enabled &&
shellExecutionConfig.sandboxConfig?.command === 'windows-native' &&
!shellExecutionConfig.sandboxConfig?.networkAccess;
const child = cpSpawn(executable, spawnArgs, {
const finalShell = isStrictSandbox ? 'cmd' : shell;
let finalArgsPrefix: string[] = [];
if (finalShell === 'cmd') {
finalArgsPrefix = ['/c'];
} else {
finalArgsPrefix = argsPrefix;
}
// We still use the original executable logic (e.g. searching for git)
// but the guard command formatting is based on the final shell.
const guardedCommand = ensurePromptvarsDisabled(commandToExecute, finalShell as ShellType);
const spawnArgs = [...finalArgsPrefix, guardedCommand];
const env = {
...process.env,
[GEMINI_CLI_IDENTIFICATION_ENV_VAR]:
GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE,
TERM: 'xterm-256color',
PAGER: 'cat',
GIT_PAGER: 'cat',
};
const {
program: finalExecutable,
args: finalArgs,
env: finalEnv,
} = await this.prepareExecution(
executable,
spawnArgs,
cwd,
env,
shellExecutionConfig,
);
const child = cpSpawn(finalExecutable, finalArgs, {
cwd,
stdio: ['ignore', 'pipe', 'pipe'],
windowsVerbatimArguments: isWindows ? false : undefined,
shell: false,
detached: !isWindows,
env: {
...sanitizeEnvironment(process.env, sanitizationConfig),
[GEMINI_CLI_IDENTIFICATION_ENV_VAR]:
GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE,
TERM: 'xterm-256color',
PAGER: 'cat',
GIT_PAGER: 'cat',
},
env: finalEnv,
});
const state = {
@@ -418,6 +473,12 @@ export class ShellExecutionService {
let combinedOutput = state.output;
const isSandboxError = code === 3221225781 || code === -1073741515; // 0xC0000135
if (isSandboxError && shellExecutionConfig.sandboxConfig?.enabled) {
const sandboxMessage = `\\n[GEMINI_CLI_SANDBOX_ERROR: Command execution was blocked by the native Windows sandbox. This typically means the command attempted an unauthorized network request or file access.]`;
combinedOutput += sandboxMessage;
}
if (state.truncated) {
const truncationMessage = `\n[GEMINI_CLI_WARNING: Output truncated. The buffer is limited to ${
MAX_CHILD_PROCESS_BUFFER_SIZE / (1024 * 1024)
@@ -559,32 +620,49 @@ export class ShellExecutionService {
const rows = shellExecutionConfig.terminalHeight ?? 30;
const { executable, argsPrefix, shell } = getShellConfiguration();
const resolvedExecutable = await resolveExecutable(executable);
if (!resolvedExecutable) {
throw new Error(
`Shell executable "${executable}" not found in PATH or at absolute location. Please ensure the shell is installed and available in your environment.`,
);
const isStrictSandbox =
shellExecutionConfig.sandboxConfig?.enabled &&
shellExecutionConfig.sandboxConfig?.command === 'windows-native' &&
!shellExecutionConfig.sandboxConfig?.networkAccess;
const finalShell = isStrictSandbox ? 'cmd' : shell;
let finalArgsPrefix: string[] = [];
if (finalShell === 'cmd') {
finalArgsPrefix = ['/c'];
} else {
finalArgsPrefix = argsPrefix;
}
const guardedCommand = ensurePromptvarsDisabled(commandToExecute, shell);
const args = [...argsPrefix, guardedCommand];
const guardedCommand = ensurePromptvarsDisabled(commandToExecute, finalShell as ShellType);
const args = [...finalArgsPrefix, guardedCommand];
const env = {
...process.env,
GEMINI_CLI: '1',
TERM: 'xterm-256color',
PAGER: shellExecutionConfig.pager ?? 'cat',
GIT_PAGER: shellExecutionConfig.pager ?? 'cat',
};
const {
program: finalExecutable,
args: finalArgs,
env: finalEnv,
} = await this.prepareExecution(
executable,
args,
cwd,
env,
shellExecutionConfig,
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const ptyProcess = ptyInfo.module.spawn(executable, args, {
const ptyProcess = ptyInfo.module.spawn(finalExecutable, finalArgs, {
cwd,
name: 'xterm-256color',
cols,
rows,
env: {
...sanitizeEnvironment(
process.env,
shellExecutionConfig.sanitizationConfig,
),
GEMINI_CLI: '1',
TERM: 'xterm-256color',
PAGER: shellExecutionConfig.pager ?? 'cat',
GIT_PAGER: shellExecutionConfig.pager ?? 'cat',
},
env: finalEnv,
handleFlowControl: true,
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
@@ -808,6 +886,13 @@ export class ShellExecutionService {
// Store exit info for late subscribers (e.g. backgrounding race condition)
this.exitedPtyInfo.set(ptyProcess.pid, { exitCode, signal });
const isSandboxError = exitCode === 3221225781 || exitCode === -1073741515; // 0xC0000135
let finalOutput = getFullBufferText(headlessTerminal);
if (isSandboxError && shellExecutionConfig.sandboxConfig?.enabled) {
finalOutput += `\n[GEMINI_CLI_SANDBOX_ERROR: Command execution was blocked by the native Windows sandbox. This typically means the command attempted an unauthorized network request or file access.]`;
}
setTimeout(
() => {
this.exitedPtyInfo.delete(ptyProcess.pid);
@@ -831,7 +916,7 @@ export class ShellExecutionService {
resolve({
rawOutput: finalBuffer,
output: getFullBufferText(headlessTerminal),
output: finalOutput,
exitCode,
signal: signal ?? null,
error,
@@ -0,0 +1,46 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { WindowsSandboxManager } from './windowsSandboxManager.js';
import type { SandboxRequest } from './sandboxManager.js';
import * as os from 'node:os';
describe('WindowsSandboxManager', () => {
const manager = new WindowsSandboxManager();
it.skipIf(os.platform() !== 'win32')('should prepare a GeminiSandbox.exe command', async () => {
const req: SandboxRequest = {
command: 'whoami',
args: ['/groups'],
cwd: process.cwd(),
env: { TEST_VAR: 'test_value' },
config: {
networkAccess: false
}
};
const result = await manager.prepareCommand(req);
expect(result.program).toContain('GeminiSandbox.exe');
expect(result.args).toEqual(expect.arrayContaining(['0', process.cwd(), 'whoami', '/groups']));
});
it.skipIf(os.platform() !== 'win32')('should handle networkAccess from config', async () => {
const req: SandboxRequest = {
command: 'whoami',
args: [],
cwd: process.cwd(),
env: {},
config: {
networkAccess: true
}
};
const result = await manager.prepareCommand(req);
expect(result.args[0]).toBe('1');
});
});
@@ -0,0 +1,138 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
type SandboxManager,
type SandboxRequest,
type SandboxedCommand,
} from './sandboxManager.js';
import {
sanitizeEnvironment,
type EnvironmentSanitizationConfig,
} from './environmentSanitization.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
/**
* A SandboxManager implementation for Windows that uses Restricted Tokens,
* Job Objects, and Low Integrity levels for process isolation.
* Uses a native C# helper to bypass PowerShell restrictions.
*/
export class WindowsSandboxManager implements SandboxManager {
private readonly helperPath: string;
private initialized = false;
constructor() {
this.helperPath = path.resolve(
__dirname,
'scripts',
'GeminiSandbox.exe',
);
}
private ensureInitialized(): void {
if (this.initialized) return;
if (!fs.existsSync(this.helperPath)) {
// If the exe doesn't exist, we try to compile it from the .cs file
const sourcePath = this.helperPath.replace(/\.exe$/, '.cs');
if (fs.existsSync(sourcePath)) {
const csc = path.join(
process.env['SystemRoot'] || 'C:\\Windows',
'Microsoft.NET',
'Framework64',
'v4.0.30319',
'csc.exe',
);
if (fs.existsSync(csc)) {
spawnSync(csc, ['/out:' + this.helperPath, sourcePath], { stdio: 'ignore' });
}
}
}
this.initialized = true;
}
/**
* Prepares a command for sandboxed execution on Windows.
*/
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
this.ensureInitialized();
const sanitizationConfig: EnvironmentSanitizationConfig = {
allowedEnvironmentVariables:
req.config?.sanitizationConfig?.allowedEnvironmentVariables ?? [],
blockedEnvironmentVariables:
req.config?.sanitizationConfig?.blockedEnvironmentVariables ?? [],
enableEnvironmentVariableRedaction:
req.config?.sanitizationConfig?.enableEnvironmentVariableRedaction ??
true,
};
const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig);
// 1. Handle filesystem permissions for Low Integrity
// Grant "Low Mandatory Level" write access to the CWD.
this.grantLowIntegrityAccess(req.cwd);
// Whitelist essential system paths for DLL loading and basic tool execution.
// This is required when using the Restricted Code SID (S-1-5-12).
const systemPaths = [
process.env['SystemRoot'] || 'C:\\Windows',
process.env['ProgramFiles'] || 'C:\\Program Files',
process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)',
];
for (const sysPath of systemPaths) {
if (fs.existsSync(sysPath)) {
this.grantLowIntegrityAccess(sysPath);
}
}
// Grant "Low Mandatory Level" read access to allowedPaths.
if (req.config?.allowedPaths) {
for (const allowedPath of req.config.allowedPaths) {
this.grantLowIntegrityAccess(allowedPath);
}
}
// 2. Construct the helper command
// GeminiSandbox.exe <network:0|1> <cwd> <command> [args...]
const program = this.helperPath;
// If the command starts with __, it's an internal command for the sandbox helper itself.
const args = [
req.config?.networkAccess ? '1' : '0',
req.cwd,
req.command,
...req.args,
];
return {
program,
args,
env: sanitizedEnv,
};
}
/**
* Grants "Low Mandatory Level" access to a path using icacls.
*/
private grantLowIntegrityAccess(targetPath: string): void {
try {
spawnSync('icacls', [targetPath, '/setintegritylevel', '(OI)(CI)Low'], {
stdio: 'ignore',
});
} catch (e) {
// Best effort
}
}
}
+40 -12
View File
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -290,15 +290,41 @@ class GrepToolInvocation extends BaseToolInvocation<
* @param {string} command The command name (e.g., 'git', 'grep').
* @returns {Promise<boolean>} True if the command is available, false otherwise.
*/
private isCommandAvailable(command: string): Promise<boolean> {
return new Promise((resolve) => {
const checkCommand = process.platform === 'win32' ? 'where' : 'command';
const checkArgs =
process.platform === 'win32' ? [command] : ['-v', command];
try {
const child = spawn(checkCommand, checkArgs, {
private async isCommandAvailable(command: string): Promise<boolean> {
const checkCommand = process.platform === 'win32' ? 'where' : 'command';
const checkArgs =
process.platform === 'win32' ? [command] : ['-v', command];
try {
const sandboxManager = this.config.sandboxManager;
let finalCommand = checkCommand;
let finalArgs = checkArgs;
let finalEnv = process.env;
if (sandboxManager) {
try {
const prepared = await sandboxManager.prepareCommand({
command: checkCommand,
args: checkArgs,
cwd: process.cwd(),
env: process.env,
});
finalCommand = prepared.program;
finalArgs = prepared.args;
finalEnv = prepared.env;
} catch (err) {
debugLogger.debug(
`[GrepTool] Sandbox preparation failed for '${command}':`,
err,
);
}
}
return await new Promise((resolve) => {
const child = spawn(finalCommand, finalArgs, {
stdio: 'ignore',
shell: true,
env: finalEnv,
});
child.on('close', (code) => resolve(code === 0));
child.on('error', (err) => {
@@ -308,10 +334,10 @@ class GrepToolInvocation extends BaseToolInvocation<
);
resolve(false);
});
} catch {
resolve(false);
}
});
});
} catch {
return false;
}
}
/**
@@ -370,6 +396,7 @@ class GrepToolInvocation extends BaseToolInvocation<
cwd: absolutePath,
signal: options.signal,
allowedExitCodes: [0, 1],
sandboxManager: this.config.sandboxManager,
});
const results: GrepMatch[] = [];
@@ -441,6 +468,7 @@ class GrepToolInvocation extends BaseToolInvocation<
cwd: absolutePath,
signal: options.signal,
allowedExitCodes: [0, 1],
sandboxManager: this.config.sandboxManager,
});
for await (const line of generator) {
+1
View File
@@ -476,6 +476,7 @@ class GrepToolInvocation extends BaseToolInvocation<
const generator = execStreaming(rgPath, rgArgs, {
signal: options.signal,
allowedExitCodes: [0, 1],
sandboxManager: this.config.sandboxManager,
});
let matchesFound = 0;
+22 -4
View File
@@ -45,6 +45,7 @@ import { initializeShellParsers } from '../utils/shell-utils.js';
import { ShellTool, OUTPUT_UPDATE_INTERVAL_MS } from './shell.js';
import { debugLogger } from '../index.js';
import { type Config } from '../config/config.js';
import { NoopSandboxManager } from '../services/sandboxManager.js';
import {
type ShellExecutionResult,
type ShellOutputEvent,
@@ -130,6 +131,7 @@ describe('ShellTool', () => {
getEnableInteractiveShell: vi.fn().mockReturnValue(false),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
sanitizationConfig: {},
sandboxManager: new NoopSandboxManager(),
} as unknown as Config;
const bus = createMockMessageBus();
@@ -274,7 +276,11 @@ describe('ShellTool', () => {
expect.any(Function),
expect.any(AbortSignal),
false,
{ pager: 'cat', sanitizationConfig: {} },
expect.objectContaining({
pager: 'cat',
sanitizationConfig: {},
sandboxManager: expect.any(Object),
}),
);
expect(result.llmContent).toContain('Background PIDs: 54322');
// The file should be deleted by the tool
@@ -299,7 +305,11 @@ describe('ShellTool', () => {
expect.any(Function),
expect.any(AbortSignal),
false,
{ pager: 'cat', sanitizationConfig: {} },
expect.objectContaining({
pager: 'cat',
sanitizationConfig: {},
sandboxManager: expect.any(Object),
}),
);
});
@@ -320,7 +330,11 @@ describe('ShellTool', () => {
expect.any(Function),
expect.any(AbortSignal),
false,
{ pager: 'cat', sanitizationConfig: {} },
expect.objectContaining({
pager: 'cat',
sanitizationConfig: {},
sandboxManager: expect.any(Object),
}),
);
});
@@ -366,7 +380,11 @@ describe('ShellTool', () => {
expect.any(Function),
expect.any(AbortSignal),
false,
{ pager: 'cat', sanitizationConfig: {} },
{
pager: 'cat',
sanitizationConfig: {},
sandboxManager: new NoopSandboxManager(),
},
);
},
20000,
+1
View File
@@ -277,6 +277,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
sanitizationConfig:
shellExecutionConfig?.sanitizationConfig ??
this.config.sanitizationConfig,
sandboxManager: this.config.sandboxManager,
},
);
+52 -3
View File
@@ -57,7 +57,28 @@ class DiscoveredToolInvocation extends BaseToolInvocation<
_updateOutput?: (output: string) => void,
): Promise<ToolResult> {
const callCommand = this.config.getToolCallCommand()!;
const child = spawn(callCommand, [this.originalToolName]);
const args = [this.originalToolName];
let finalCommand = callCommand;
let finalArgs = args;
let finalEnv = process.env;
const sandboxManager = this.config.sandboxManager;
if (sandboxManager) {
const prepared = await sandboxManager.prepareCommand({
command: callCommand,
args,
cwd: process.cwd(),
env: process.env,
});
finalCommand = prepared.program;
finalArgs = prepared.args;
finalEnv = prepared.env;
}
const child = spawn(finalCommand, finalArgs, {
env: finalEnv,
});
child.stdin.write(JSON.stringify(this.params));
child.stdin.end();
@@ -326,8 +347,36 @@ export class ToolRegistry {
'Tool discovery command is empty or contains only whitespace.',
);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const proc = spawn(cmdParts[0] as string, cmdParts.slice(1) as string[]);
const firstPart = cmdParts[0];
if (typeof firstPart !== 'string') {
throw new Error(
'Tool discovery command must start with a program name.',
);
}
let finalCommand: string = firstPart;
let finalArgs: string[] = cmdParts
.slice(1)
.filter((p): p is string => typeof p === 'string');
let finalEnv = process.env;
const sandboxManager = this.config.sandboxManager;
if (sandboxManager) {
const prepared = await sandboxManager.prepareCommand({
command: finalCommand,
args: finalArgs,
cwd: process.cwd(),
env: process.env,
});
finalCommand = prepared.program;
finalArgs = prepared.args;
finalEnv = prepared.env;
}
const proc = spawn(finalCommand, finalArgs, {
env: finalEnv,
});
let stdout = '';
const stdoutDecoder = new StringDecoder('utf8');
let stderr = '';
+35 -7
View File
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -17,6 +17,8 @@ import * as readline from 'node:readline';
import { Language, Parser, Query, type Node, type Tree } from 'web-tree-sitter';
import { loadWasmBinary } from './fileUtils.js';
import { debugLogger } from './debugLogger.js';
import type { SandboxManager } from '../services/sandboxManager.js';
import { NoopSandboxManager } from '../services/sandboxManager.js';
export const SHELL_TOOL_NAMES = ['run_shell_command', 'ShellTool'];
@@ -737,13 +739,26 @@ export function stripShellWrapper(command: string): string {
* @param config The application configuration.
* @returns An object with 'allowed' boolean and optional 'reason' string if not allowed.
*/
export const spawnAsync = (
export const spawnAsync = async (
command: string,
args: string[],
options?: SpawnOptionsWithoutStdio,
): Promise<{ stdout: string; stderr: string }> =>
new Promise((resolve, reject) => {
const child = spawn(command, args, options);
options?: SpawnOptionsWithoutStdio & { sandboxManager?: SandboxManager },
): Promise<{ stdout: string; stderr: string }> => {
const sandboxManager = options?.sandboxManager ?? new NoopSandboxManager();
const prepared = await sandboxManager.prepareCommand({
command,
args,
cwd: options?.cwd?.toString() ?? process.cwd(),
env: options?.env ?? process.env,
});
const { program: finalCommand, args: finalArgs, env: finalEnv } = prepared;
return new Promise((resolve, reject) => {
const child = spawn(finalCommand, finalArgs, {
...options,
env: finalEnv,
});
let stdout = '';
let stderr = '';
@@ -767,6 +782,7 @@ export const spawnAsync = (
reject(err);
});
});
};
/**
* Executes a command and yields lines of output as they appear.
@@ -782,10 +798,22 @@ export async function* execStreaming(
options?: SpawnOptionsWithoutStdio & {
signal?: AbortSignal;
allowedExitCodes?: number[];
sandboxManager?: SandboxManager;
},
): AsyncGenerator<string, void, void> {
const child = spawn(command, args, {
const sandboxManager = options?.sandboxManager ?? new NoopSandboxManager();
const prepared = await sandboxManager.prepareCommand({
command,
args,
cwd: options?.cwd?.toString() ?? process.cwd(),
env: options?.env ?? process.env,
});
const { program: finalCommand, args: finalArgs, env: finalEnv } = prepared;
const child = spawn(finalCommand, finalArgs, {
...options,
env: finalEnv,
// ensure we don't open a window on windows if possible/relevant
windowsHide: true,
});
+1
View File
@@ -6,3 +6,4 @@
export * from './file-system-test-helpers.js';
export * from './test-rig.js';
export * from './mock-utils.js';
+18
View File
@@ -0,0 +1,18 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { SandboxConfig } from '@google/gemini-cli-core';
export function createMockSandboxConfig(
overrides?: Partial<SandboxConfig>,
): SandboxConfig {
return {
enabled: true,
allowedPaths: [],
networkAccess: false,
...overrides,
};
}