mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-27 10:11:00 -07:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12a2a9725b | |||
| 1633cd88ac | |||
| 5c0b0f98ec | |||
| 1cb703b405 | |||
| 002a57efeb | |||
| 863a0aa01e |
Generated
+25
-1
@@ -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;
|
||||
|
||||
|
||||
@@ -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,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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -277,6 +277,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
sanitizationConfig:
|
||||
shellExecutionConfig?.sanitizationConfig ??
|
||||
this.config.sanitizationConfig,
|
||||
sandboxManager: this.config.sandboxManager,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -6,3 +6,4 @@
|
||||
|
||||
export * from './file-system-test-helpers.js';
|
||||
export * from './test-rig.js';
|
||||
export * from './mock-utils.js';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user