From fa024133e6303be0856c6e12de051e63508fb396 Mon Sep 17 00:00:00 2001 From: Gal Zahavi <38544478+galz10@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:11:51 -0700 Subject: [PATCH] feat(core): integrate SandboxManager to sandbox all process-spawning tools (#22231) --- docs/cli/settings.md | 1 + docs/reference/configuration.md | 12 +- package-lock.json | 26 ++- packages/a2a-server/src/commands/memory.ts | 1 + .../a2a-server/src/utils/testing_utils.ts | 9 + packages/cli/src/acp/commands/memory.ts | 1 + packages/cli/src/config/config.ts | 1 + .../config/extension-manager-themes.spec.ts | 8 +- packages/cli/src/config/sandboxConfig.ts | 4 +- packages/cli/src/config/settingsSchema.ts | 12 +- .../prompt-processors/shellProcessor.test.ts | 10 +- packages/cli/src/test-utils/mockConfig.ts | 10 +- packages/cli/src/ui/AppContainer.tsx | 1 + .../ui/hooks/shellCommandProcessor.test.tsx | 9 +- .../core/src/config/agent-loop-context.ts | 4 + packages/core/src/config/config.ts | 29 +++- .../src/config/sandbox-integration.test.ts | 65 +++++++ .../core/src/core/coreToolScheduler.test.ts | 4 + packages/core/src/index.ts | 1 + .../src/services/environmentSanitization.ts | 4 +- .../core/src/services/sandboxManager.test.ts | 4 +- packages/core/src/services/sandboxManager.ts | 29 +++- .../services/shellExecutionService.test.ts | 61 ++++++- .../src/services/shellExecutionService.ts | 158 ++++++++++++------ packages/core/src/tools/grep.ts | 52 ++++-- packages/core/src/tools/ripGrep.ts | 1 + packages/core/src/tools/shell.test.ts | 26 ++- packages/core/src/tools/shell.ts | 1 + packages/core/src/tools/tool-registry.ts | 55 +++++- packages/core/src/utils/shell-utils.ts | 42 ++++- schemas/settings.schema.json | 11 +- 31 files changed, 558 insertions(+), 94 deletions(-) create mode 100644 packages/core/src/config/sandbox-integration.test.ts diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 35a09a99ab..89f1333c82 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -125,6 +125,7 @@ they appear in the UI. | UI Label | Setting | Description | Default | | ------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- | +| Tool Sandboxing | `security.toolSandboxing` | Experimental tool-level sandboxing (implementation in progress). | `false` | | Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` | | Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` | | Auto-add to Policy by Default | `security.autoAddToPolicyByDefault` | When enabled, the "Allow for all future sessions" option becomes the default choice for low-risk tools in trusted workspaces. | `false` | diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 4b53866247..6b67652745 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -784,9 +784,10 @@ their corresponding top-level category object in your `settings.json` file. #### `tools` - **`tools.sandbox`** (string): - - **Description:** Sandbox execution environment. Set to a boolean to enable - or disable the sandbox, provide a string path to a sandbox profile, or - specify an explicit sandbox command (e.g., "docker", "podman", "lxc"). + - **Description:** Legacy full-process sandbox execution environment. Set to a + boolean to enable or disable the sandbox, provide a string path to a sandbox + profile, or specify an explicit sandbox command (e.g., "docker", "podman", + "lxc"). - **Default:** `undefined` - **Requires restart:** Yes @@ -890,6 +891,11 @@ their corresponding top-level category object in your `settings.json` file. #### `security` +- **`security.toolSandboxing`** (boolean): + - **Description:** Experimental tool-level sandboxing (implementation in + progress). + - **Default:** `false` + - **`security.disableYoloMode`** (boolean): - **Description:** Disable YOLO mode, even if enabled by a flag. - **Default:** `false` diff --git a/package-lock.json b/package-lock.json index bf21f81b8f..ad4c9971db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2195,6 +2195,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", @@ -2375,6 +2376,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" } @@ -2424,6 +2426,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" }, @@ -2798,6 +2801,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" @@ -2831,6 +2835,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" @@ -2885,6 +2890,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", @@ -4087,6 +4093,7 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4361,6 +4368,7 @@ "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/types": "8.35.0", @@ -5234,6 +5242,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" }, @@ -7765,6 +7774,7 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -8275,6 +8285,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", @@ -9559,6 +9570,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz", "integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -9838,6 +9850,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", @@ -13440,6 +13453,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" } @@ -13450,6 +13464,7 @@ "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -15497,6 +15512,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -15720,7 +15736,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", @@ -15728,6 +15745,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" @@ -15887,6 +15905,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16109,6 +16128,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", @@ -16222,6 +16242,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16234,6 +16255,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", @@ -16875,6 +16897,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" } @@ -17417,6 +17440,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/packages/a2a-server/src/commands/memory.ts b/packages/a2a-server/src/commands/memory.ts index d01ff5e7d4..b29b8ae4d5 100644 --- a/packages/a2a-server/src/commands/memory.ts +++ b/packages/a2a-server/src/commands/memory.ts @@ -104,6 +104,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 { diff --git a/packages/a2a-server/src/utils/testing_utils.ts b/packages/a2a-server/src/utils/testing_utils.ts index c55eae98ee..83c66aab99 100644 --- a/packages/a2a-server/src/utils/testing_utils.ts +++ b/packages/a2a-server/src/utils/testing_utils.ts @@ -21,6 +21,7 @@ import { tmpdir, type Config, type Storage, + NoopSandboxManager, type ToolRegistry, } from '@google/gemini-cli-core'; import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js'; @@ -97,6 +98,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; diff --git a/packages/cli/src/acp/commands/memory.ts b/packages/cli/src/acp/commands/memory.ts index 9460af7ad1..1154c852a1 100644 --- a/packages/cli/src/acp/commands/memory.ts +++ b/packages/cli/src/acp/commands/memory.ts @@ -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 { diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index e910d47546..769583ea62 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -744,6 +744,7 @@ export async function loadCliConfig( clientVersion: await getVersion(), embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL, sandbox: sandboxConfig, + toolSandboxing: settings.security?.toolSandboxing ?? false, targetDir: cwd, includeDirectoryTree, includeDirectories, diff --git a/packages/cli/src/config/extension-manager-themes.spec.ts b/packages/cli/src/config/extension-manager-themes.spec.ts index b1b21aab55..9358784a2f 100644 --- a/packages/cli/src/config/extension-manager-themes.spec.ts +++ b/packages/cli/src/config/extension-manager-themes.spec.ts @@ -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: [], diff --git a/packages/cli/src/config/sandboxConfig.ts b/packages/cli/src/config/sandboxConfig.ts index cce5033f1a..59a9685f70 100644 --- a/packages/cli/src/config/sandboxConfig.ts +++ b/packages/cli/src/config/sandboxConfig.ts @@ -34,7 +34,9 @@ const VALID_SANDBOX_COMMANDS = [ function isSandboxCommand( value: string, ): value is Exclude { - return VALID_SANDBOX_COMMANDS.includes(value); + return (VALID_SANDBOX_COMMANDS as ReadonlyArray).includes( + value, + ); } function getSandboxCommand( diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 0e7b88d76d..0f9be83236 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1300,7 +1300,7 @@ const SETTINGS_SCHEMA = { default: undefined as boolean | string | SandboxConfig | undefined, ref: 'BooleanOrStringOrObject', description: oneLine` - Sandbox execution environment. + Legacy full-process sandbox execution environment. Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile, or specify an explicit sandbox command (e.g., "docker", "podman", "lxc"). `, @@ -1522,6 +1522,16 @@ const SETTINGS_SCHEMA = { description: 'Security-related settings.', showInDialog: false, properties: { + toolSandboxing: { + type: 'boolean', + label: 'Tool Sandboxing', + category: 'Security', + requiresRestart: false, + default: false, + description: + 'Experimental tool-level sandboxing (implementation in progress).', + showInDialog: true, + }, disableYoloMode: { type: 'boolean', label: 'Disable YOLO Mode', diff --git a/packages/cli/src/services/prompt-processors/shellProcessor.test.ts b/packages/cli/src/services/prompt-processors/shellProcessor.test.ts index 0f6fb562a8..84010ab625 100644 --- a/packages/cli/src/services/prompt-processors/shellProcessor.test.ts +++ b/packages/cli/src/services/prompt-processors/shellProcessor.test.ts @@ -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, }), diff --git a/packages/cli/src/test-utils/mockConfig.ts b/packages/cli/src/test-utils/mockConfig.ts index 170d009843..1039d15c14 100644 --- a/packages/cli/src/test-utils/mockConfig.ts +++ b/packages/cli/src/test-utils/mockConfig.ts @@ -5,6 +5,7 @@ */ import { vi } from 'vitest'; +import { NoopSandboxManager } from '@google/gemini-cli-core'; import type { Config } from '@google/gemini-cli-core'; import { createTestMergedSettings, @@ -131,7 +132,14 @@ export const createMockConfig = (overrides: Partial = {}): Config => getRetryFetchErrors: vi.fn().mockReturnValue(true), 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), diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 0bfdeba120..fa0a293916 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1425,6 +1425,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(); diff --git a/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx b/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx index b8486bc378..f5e3b61e2b 100644 --- a/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx +++ b/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx @@ -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'); diff --git a/packages/core/src/config/agent-loop-context.ts b/packages/core/src/config/agent-loop-context.ts index 92eff0c3c1..0a879d9c93 100644 --- a/packages/core/src/config/agent-loop-context.ts +++ b/packages/core/src/config/agent-loop-context.ts @@ -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'; import type { Config } from './config.js'; /** @@ -28,4 +29,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; } diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index e97d4859f2..18dd627ea0 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -41,6 +41,10 @@ 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 { + createSandboxManager, + type SandboxManager, +} from '../services/sandboxManager.js'; import { initializeTelemetry, DEFAULT_TELEMETRY_TARGET, @@ -510,6 +514,7 @@ export interface ConfigParameters { clientVersion?: string; embeddingModel?: string; sandbox?: SandboxConfig; + toolSandboxing?: boolean; targetDir: string; debugMode: boolean; question?: string; @@ -686,6 +691,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; @@ -855,7 +861,19 @@ export class Config implements McpContext, AgentLoopContext { 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, []); @@ -985,6 +1003,7 @@ export class Config implements McpContext, AgentLoopContext { showColor: params.shellExecutionConfig?.showColor ?? false, pager: params.shellExecutionConfig?.pager ?? 'cat', sanitizationConfig: this.sanitizationConfig, + sandboxManager: this.sandboxManager, }; this.truncateToolOutputThreshold = params.truncateToolOutputThreshold ?? @@ -1102,6 +1121,8 @@ export class Config implements McpContext, AgentLoopContext { } } this._geminiClient = new GeminiClient(this); + this._sandboxManager = createSandboxManager(params.toolSandboxing ?? false); + this.shellExecutionConfig.sandboxManager = this._sandboxManager; this.modelRouterService = new ModelRouterService(this); // HACK: The settings loading logic doesn't currently merge the default @@ -1423,6 +1444,10 @@ export class Config implements McpContext, AgentLoopContext { return this._geminiClient; } + get sandboxManager(): SandboxManager { + return this._sandboxManager; + } + getSessionId(): string { return this.promptId; } @@ -2810,6 +2835,8 @@ export class Config implements McpContext, AgentLoopContext { sanitizationConfig: config.sanitizationConfig ?? this.shellExecutionConfig.sanitizationConfig, + sandboxManager: + config.sandboxManager ?? this.shellExecutionConfig.sandboxManager, }; } getScreenReader(): boolean { diff --git a/packages/core/src/config/sandbox-integration.test.ts b/packages/core/src/config/sandbox-integration.test.ts new file mode 100644 index 0000000000..305b9e2638 --- /dev/null +++ b/packages/core/src/config/sandbox-integration.test.ts @@ -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(); + 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); + }); +}); diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index acd091a27b..3a9d0e2e92 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -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 { allowedEnvironmentVariables: [], blockedEnvironmentVariables: [], }, + sandboxManager: new NoopSandboxManager(), }), storage: { getProjectTempDir: () => '/tmp', @@ -1211,6 +1213,7 @@ describe('CoreToolScheduler request queueing', () => { allowedEnvironmentVariables: [], blockedEnvironmentVariables: [], }, + sandboxManager: new NoopSandboxManager(), }), isInteractive: () => false, }); @@ -1320,6 +1323,7 @@ describe('CoreToolScheduler request queueing', () => { allowedEnvironmentVariables: [], blockedEnvironmentVariables: [], }, + sandboxManager: new NoopSandboxManager(), }), getToolRegistry: () => toolRegistry, getHookSystem: () => undefined, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b846e2f2e9..b395daf2f9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -146,6 +146,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'; diff --git a/packages/core/src/services/environmentSanitization.ts b/packages/core/src/services/environmentSanitization.ts index 9d35249a8e..ee7c824e9c 100644 --- a/packages/core/src/services/environmentSanitization.ts +++ b/packages/core/src/services/environmentSanitization.ts @@ -125,7 +125,7 @@ export const NEVER_ALLOWED_VALUE_PATTERNS = [ /-----BEGIN (RSA|OPENSSH|EC|PGP) PRIVATE KEY-----/i, /-----BEGIN CERTIFICATE-----/i, // Credentials in URL - /(https?|ftp|smtp):\/\/[^:]+:[^@]+@/i, + /(https?|ftp|smtp):\/\/[^:\s]{1,1024}:[^@\s]{1,1024}@/i, // GitHub tokens (classic, fine-grained, OAuth, etc.) /(ghp|gho|ghu|ghs|ghr|github_pat)_[a-zA-Z0-9_]{36,}/i, // Google API keys @@ -133,7 +133,7 @@ export const NEVER_ALLOWED_VALUE_PATTERNS = [ // Amazon AWS Access Key ID /AKIA[A-Z0-9]{16}/i, // Generic OAuth/JWT tokens - /eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*/i, + /eyJ[a-zA-Z0-9_-]{0,10240}\.[a-zA-Z0-9_-]{0,10240}\.[a-zA-Z0-9_-]{0,10240}/i, // Stripe API keys /(s|r)k_(live|test)_[0-9a-zA-Z]{24}/i, // Slack tokens (bot, user, etc.) diff --git a/packages/core/src/services/sandboxManager.test.ts b/packages/core/src/services/sandboxManager.test.ts index bac8a8a55c..963dbf8ccf 100644 --- a/packages/core/src/services/sandboxManager.test.ts +++ b/packages/core/src/services/sandboxManager.test.ts @@ -45,7 +45,7 @@ describe('NoopSandboxManager', () => { expect(result.env['MY_SECRET']).toBeUndefined(); }); - it('should force environment variable redaction even if not requested in config', async () => { + it('should allow disabling environment variable redaction if requested in config', async () => { const req = { command: 'echo', args: ['hello'], @@ -62,7 +62,7 @@ describe('NoopSandboxManager', () => { const result = await sandboxManager.prepareCommand(req); - expect(result.env['API_KEY']).toBeUndefined(); + expect(result.env['API_KEY']).toBe('sensitive-key'); }); it('should respect allowedEnvironmentVariables in config', async () => { diff --git a/packages/core/src/services/sandboxManager.ts b/packages/core/src/services/sandboxManager.ts index 458e15260e..f2435fa56b 100644 --- a/packages/core/src/services/sandboxManager.ts +++ b/packages/core/src/services/sandboxManager.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -37,6 +37,8 @@ export interface SandboxedCommand { args: string[]; /** Sanitized environment variables. */ env: NodeJS.ProcessEnv; + /** The working directory. */ + cwd?: string; } /** @@ -64,7 +66,9 @@ export class NoopSandboxManager implements SandboxManager { req.config?.sanitizationConfig?.allowedEnvironmentVariables ?? [], blockedEnvironmentVariables: req.config?.sanitizationConfig?.blockedEnvironmentVariables ?? [], - enableEnvironmentVariableRedaction: true, // Forced for safety + enableEnvironmentVariableRedaction: + req.config?.sanitizationConfig?.enableEnvironmentVariableRedaction ?? + true, }; const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig); @@ -76,3 +80,24 @@ export class NoopSandboxManager implements SandboxManager { }; } } + +/** + * SandboxManager that implements actual sandboxing. + */ +export class LocalSandboxManager implements SandboxManager { + async prepareCommand(_req: SandboxRequest): Promise { + throw new Error('Tool sandboxing is not yet implemented.'); + } +} + +/** + * Creates a sandbox manager based on the provided settings. + */ +export function createSandboxManager( + sandboxingEnabled: boolean, +): SandboxManager { + if (sandboxingEnabled) { + return new LocalSandboxManager(); + } + return new NoopSandboxManager(); +} diff --git a/packages/core/src/services/shellExecutionService.test.ts b/packages/core/src/services/shellExecutionService.test.ts index 0eab28017a..a828771c25 100644 --- a/packages/core/src/services/shellExecutionService.test.ts +++ b/packages/core/src/services/shellExecutionService.test.ts @@ -22,6 +22,7 @@ import { type ShellOutputEvent, type ShellExecutionConfig, } from './shellExecutionService.js'; +import { NoopSandboxManager } from './sandboxManager.js'; import { ExecutionLifecycleService } from './executionLifecycleService.js'; import type { AnsiOutput, AnsiToken } from '../utils/terminalSerializer.js'; @@ -137,6 +138,7 @@ const shellExecutionConfig: ShellExecutionConfig = { allowedEnvironmentVariables: [], blockedEnvironmentVariables: [], }, + sandboxManager: new NoopSandboxManager(), }; const createMockSerializeTerminalToObjectReturnValue = ( @@ -625,6 +627,7 @@ describe('ShellExecutionService', () => { new AbortController().signal, true, { + ...shellExecutionConfig, sanitizationConfig: { enableEnvironmentVariableRedaction: true, allowedEnvironmentVariables: [], @@ -1396,7 +1399,7 @@ describe('ShellExecutionService child_process fallback', () => { expect(mockCpSpawn).toHaveBeenCalledWith( expectedCommand, ['/pid', String(mockChildProcess.pid), '/f', '/t'], - undefined, + expect.anything(), ); } }); @@ -1417,6 +1420,7 @@ describe('ShellExecutionService child_process fallback', () => { abortController.signal, true, { + ...shellExecutionConfig, sanitizationConfig: { enableEnvironmentVariableRedaction: true, allowedEnvironmentVariables: [], @@ -1631,6 +1635,7 @@ describe('ShellExecutionService execution method selection', () => { abortController.signal, false, // shouldUseNodePty { + ...shellExecutionConfig, sanitizationConfig: { enableEnvironmentVariableRedaction: true, allowedEnvironmentVariables: [], @@ -1778,6 +1783,7 @@ describe('ShellExecutionService environment variables', () => { new AbortController().signal, true, { + ...shellExecutionConfig, sanitizationConfig: { enableEnvironmentVariableRedaction: false, allowedEnvironmentVariables: [], @@ -1837,6 +1843,7 @@ describe('ShellExecutionService environment variables', () => { new AbortController().signal, true, { + ...shellExecutionConfig, sanitizationConfig: { enableEnvironmentVariableRedaction: false, allowedEnvironmentVariables: [], @@ -1904,6 +1911,58 @@ describe('ShellExecutionService environment variables', () => { 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; + }); + it('should include headless git and gh environment variables in non-interactive mode and append git config safely', async () => { vi.resetModules(); vi.stubEnv('GIT_CONFIG_COUNT', '2'); diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index f8d2e728d2..47601172ac 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -27,11 +27,8 @@ import { serializeTerminalToObject, type AnsiOutput, } from '../utils/terminalSerializer.js'; -import { - sanitizeEnvironment, - type EnvironmentSanitizationConfig, -} from './environmentSanitization.js'; -import { NoopSandboxManager } from './sandboxManager.js'; +import { type EnvironmentSanitizationConfig } from './environmentSanitization.js'; +import { type SandboxManager } from './sandboxManager.js'; import { killProcessGroup } from '../utils/process-utils.js'; import { ExecutionLifecycleService, @@ -90,6 +87,7 @@ export interface ShellExecutionConfig { defaultFg?: string; defaultBg?: string; sanitizationConfig: EnvironmentSanitizationConfig; + sandboxManager: SandboxManager; // Used for testing disableDynamicLineTrimming?: boolean; scrollback?: number; @@ -274,15 +272,6 @@ export class ShellExecutionService { shouldUseNodePty: boolean, shellExecutionConfig: ShellExecutionConfig, ): Promise { - const sandboxManager = new NoopSandboxManager(); - const { env: sanitizedEnv } = await sandboxManager.prepareCommand({ - command: commandToExecute, - args: [], - env: process.env, - cwd, - config: shellExecutionConfig, - }); - if (shouldUseNodePty) { const ptyInfo = await getPty(); if (ptyInfo) { @@ -294,7 +283,6 @@ export class ShellExecutionService { abortSignal, shellExecutionConfig, ptyInfo, - sanitizedEnv, ); } catch (_e) { // Fallback to child_process @@ -307,7 +295,7 @@ export class ShellExecutionService { cwd, onOutputEvent, abortSignal, - shellExecutionConfig.sanitizationConfig, + shellExecutionConfig, shouldUseNodePty, ); } @@ -342,14 +330,49 @@ export class ShellExecutionService { return { newBuffer: truncatedBuffer + chunk, truncated: true }; } - private static childProcessFallback( + private static async prepareExecution( + executable: string, + args: string[], + cwd: string, + env: NodeJS.ProcessEnv, + shellExecutionConfig: ShellExecutionConfig, + sanitizationConfigOverride?: EnvironmentSanitizationConfig, + ): Promise<{ + program: string; + args: string[]; + env: NodeJS.ProcessEnv; + cwd: string; + }> { + const resolvedExecutable = + (await resolveExecutable(executable)) ?? executable; + + const prepared = await shellExecutionConfig.sandboxManager.prepareCommand({ + command: resolvedExecutable, + args, + cwd, + env, + config: { + sanitizationConfig: + sanitizationConfigOverride ?? shellExecutionConfig.sanitizationConfig, + }, + }); + + return { + program: prepared.program, + args: prepared.args, + env: prepared.env, + cwd: prepared.cwd ?? cwd, + }; + } + + private static async childProcessFallback( commandToExecute: string, cwd: string, onOutputEvent: (event: ShellOutputEvent) => void, abortSignal: AbortSignal, - sanitizationConfig: EnvironmentSanitizationConfig, + shellExecutionConfig: ShellExecutionConfig, isInteractive: boolean, - ): ShellExecutionHandle { + ): Promise { try { const isWindows = os.platform() === 'win32'; const { executable, argsPrefix, shell } = getShellConfiguration(); @@ -361,16 +384,17 @@ export class ShellExecutionService { const gitConfigKeys = !isInteractive ? Object.keys(process.env).filter((k) => k.startsWith('GIT_CONFIG_')) : []; - const sanitizedEnv = sanitizeEnvironment(process.env, { - ...sanitizationConfig, + const localSanitizationConfig = { + ...shellExecutionConfig.sanitizationConfig, allowedEnvironmentVariables: [ - ...(sanitizationConfig.allowedEnvironmentVariables || []), + ...(shellExecutionConfig.sanitizationConfig + .allowedEnvironmentVariables || []), ...gitConfigKeys, ], - }); + }; - const env: NodeJS.ProcessEnv = { - ...sanitizedEnv, + const env = { + ...process.env, [GEMINI_CLI_IDENTIFICATION_ENV_VAR]: GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE, TERM: 'xterm-256color', @@ -378,12 +402,28 @@ export class ShellExecutionService { GIT_PAGER: 'cat', }; + const { + program: finalExecutable, + args: finalArgs, + env: sanitizedEnv, + cwd: finalCwd, + } = await this.prepareExecution( + executable, + spawnArgs, + cwd, + env, + shellExecutionConfig, + localSanitizationConfig, + ); + + const finalEnv = { ...sanitizedEnv }; + if (!isInteractive) { const gitConfigCount = parseInt( - sanitizedEnv['GIT_CONFIG_COUNT'] || '0', + finalEnv['GIT_CONFIG_COUNT'] || '0', 10, ); - Object.assign(env, { + Object.assign(finalEnv, { // Disable interactive prompts and session-linked credential helpers // in non-interactive mode to prevent hangs in detached process groups. GIT_TERMINAL_PROMPT: '0', @@ -399,13 +439,13 @@ export class ShellExecutionService { }); } - const child = cpSpawn(executable, spawnArgs, { - cwd, + const child = cpSpawn(finalExecutable, finalArgs, { + cwd: finalCwd, stdio: ['ignore', 'pipe', 'pipe'], windowsVerbatimArguments: isWindows ? false : undefined, shell: false, detached: !isWindows, - env, + env: finalEnv, }); const state = { @@ -682,7 +722,6 @@ export class ShellExecutionService { abortSignal: AbortSignal, shellExecutionConfig: ShellExecutionConfig, ptyInfo: PtyImplementation, - sanitizedEnv: Record, ): Promise { if (!ptyInfo) { // This should not happen, but as a safeguard... @@ -695,29 +734,52 @@ 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 guardedCommand = ensurePromptvarsDisabled(commandToExecute, shell); const args = [...argsPrefix, guardedCommand]; - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const ptyProcess = ptyInfo.module.spawn(executable, args, { + const env = { + ...process.env, + GEMINI_CLI: '1', + TERM: 'xterm-256color', + PAGER: shellExecutionConfig.pager ?? 'cat', + GIT_PAGER: shellExecutionConfig.pager ?? 'cat', + }; + + // Specifically allow GIT_CONFIG_* variables to pass through sanitization + // so we can safely append our overrides if needed. + const gitConfigKeys = Object.keys(process.env).filter((k) => + k.startsWith('GIT_CONFIG_'), + ); + const localSanitizationConfig = { + ...shellExecutionConfig.sanitizationConfig, + allowedEnvironmentVariables: [ + ...(shellExecutionConfig.sanitizationConfig + ?.allowedEnvironmentVariables ?? []), + ...gitConfigKeys, + ], + }; + + const { + program: finalExecutable, + args: finalArgs, + env: finalEnv, + cwd: finalCwd, + } = await this.prepareExecution( + executable, + args, cwd, + env, + shellExecutionConfig, + localSanitizationConfig, + ); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const ptyProcess = ptyInfo.module.spawn(finalExecutable, finalArgs, { + cwd: finalCwd, name: 'xterm-256color', cols, rows, - env: { - ...sanitizedEnv, - 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 diff --git a/packages/core/src/tools/grep.ts b/packages/core/src/tools/grep.ts index f0d7aaa4aa..ea202c57de 100644 --- a/packages/core/src/tools/grep.ts +++ b/packages/core/src/tools/grep.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -301,15 +301,41 @@ class GrepToolInvocation extends BaseToolInvocation< * @param {string} command The command name (e.g., 'git', 'grep'). * @returns {Promise} True if the command is available, false otherwise. */ - private isCommandAvailable(command: string): Promise { - 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 { + 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) => { @@ -319,10 +345,10 @@ class GrepToolInvocation extends BaseToolInvocation< ); resolve(false); }); - } catch { - resolve(false); - } - }); + }); + } catch { + return false; + } } /** @@ -381,6 +407,7 @@ class GrepToolInvocation extends BaseToolInvocation< cwd: absolutePath, signal: options.signal, allowedExitCodes: [0, 1], + sandboxManager: this.config.sandboxManager, }); const results: GrepMatch[] = []; @@ -452,6 +479,7 @@ class GrepToolInvocation extends BaseToolInvocation< cwd: absolutePath, signal: options.signal, allowedExitCodes: [0, 1], + sandboxManager: this.config.sandboxManager, }); for await (const line of generator) { diff --git a/packages/core/src/tools/ripGrep.ts b/packages/core/src/tools/ripGrep.ts index 18a1b0c133..69f269143b 100644 --- a/packages/core/src/tools/ripGrep.ts +++ b/packages/core/src/tools/ripGrep.ts @@ -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; diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index 5e17f29690..ace59cd7cf 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -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, @@ -137,6 +138,7 @@ describe('ShellTool', () => { getEnableInteractiveShell: vi.fn().mockReturnValue(false), getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true), sanitizationConfig: {}, + sandboxManager: new NoopSandboxManager(), } as unknown as Config; const bus = createMockMessageBus(); @@ -281,7 +283,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 @@ -306,7 +312,11 @@ describe('ShellTool', () => { expect.any(Function), expect.any(AbortSignal), false, - { pager: 'cat', sanitizationConfig: {} }, + expect.objectContaining({ + pager: 'cat', + sanitizationConfig: {}, + sandboxManager: expect.any(Object), + }), ); }); @@ -327,7 +337,11 @@ describe('ShellTool', () => { expect.any(Function), expect.any(AbortSignal), false, - { pager: 'cat', sanitizationConfig: {} }, + expect.objectContaining({ + pager: 'cat', + sanitizationConfig: {}, + sandboxManager: expect.any(Object), + }), ); }); @@ -373,7 +387,11 @@ describe('ShellTool', () => { expect.any(Function), expect.any(AbortSignal), false, - { pager: 'cat', sanitizationConfig: {} }, + { + pager: 'cat', + sanitizationConfig: {}, + sandboxManager: new NoopSandboxManager(), + }, ); }, 20000, diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index d5af530d33..069bcd5981 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -278,6 +278,7 @@ export class ShellToolInvocation extends BaseToolInvocation< sanitizationConfig: shellExecutionConfig?.sanitizationConfig ?? this.context.config.sanitizationConfig, + sandboxManager: this.context.config.sandboxManager, }, ); diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts index 51a55ce0a4..bc8e85462a 100644 --- a/packages/core/src/tools/tool-registry.ts +++ b/packages/core/src/tools/tool-registry.ts @@ -57,7 +57,28 @@ class DiscoveredToolInvocation extends BaseToolInvocation< _updateOutput?: (output: string) => void, ): Promise { 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(); @@ -322,8 +343,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 = ''; diff --git a/packages/core/src/utils/shell-utils.ts b/packages/core/src/utils/shell-utils.ts index 00b3533400..89f50a9ce7 100644 --- a/packages/core/src/utils/shell-utils.ts +++ b/packages/core/src/utils/shell-utils.ts @@ -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 { - 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, }); diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index f8fc341af8..f61690e306 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -1321,8 +1321,8 @@ "properties": { "sandbox": { "title": "Sandbox", - "description": "Sandbox execution environment. Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile, or specify an explicit sandbox command (e.g., \"docker\", \"podman\", \"lxc\").", - "markdownDescription": "Sandbox execution environment. Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile, or specify an explicit sandbox command (e.g., \"docker\", \"podman\", \"lxc\").\n\n- Category: `Tools`\n- Requires restart: `yes`", + "description": "Legacy full-process sandbox execution environment. Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile, or specify an explicit sandbox command (e.g., \"docker\", \"podman\", \"lxc\").", + "markdownDescription": "Legacy full-process sandbox execution environment. Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile, or specify an explicit sandbox command (e.g., \"docker\", \"podman\", \"lxc\").\n\n- Category: `Tools`\n- Requires restart: `yes`", "$ref": "#/$defs/BooleanOrStringOrObject" }, "shell": { @@ -1481,6 +1481,13 @@ "default": {}, "type": "object", "properties": { + "toolSandboxing": { + "title": "Tool Sandboxing", + "description": "Experimental tool-level sandboxing (implementation in progress).", + "markdownDescription": "Experimental tool-level sandboxing (implementation in progress).\n\n- Category: `Security`\n- Requires restart: `no`\n- Default: `false`", + "default": false, + "type": "boolean" + }, "disableYoloMode": { "title": "Disable YOLO Mode", "description": "Disable YOLO mode, even if enabled by a flag.",