Compare commits

...

5 Commits

Author SHA1 Message Date
galz10 e8d292cdb9 wip: dynamic expansion proto 2026-03-17 18:23:00 -07:00
galz10 7158876d77 wip 2026-03-16 14:32:27 -07:00
galz10 1dd4a7ff61 chore: remove git allowlist 2026-03-12 10:29:25 -07:00
galz10 a9608ad5b6 testing 2026-03-11 11:47:37 -07:00
galz10 3d5af8b350 WIP: macOS tool sandbox 2026-03-06 14:57:42 -08:00
51 changed files with 2462 additions and 392 deletions
+10
View File
@@ -6,5 +6,15 @@
},
"general": {
"devtools": true
},
"sandbox": {
"enabled": true
},
"tools": {
"coreTools": ["ShellTool(echo)"],
"exclude": [
"replace",
"write_file"
]
}
}
View File
+25 -1
View File
@@ -2292,6 +2292,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",
@@ -2472,6 +2473,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"
}
@@ -2521,6 +2523,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"
},
@@ -2895,6 +2898,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"
@@ -2928,6 +2932,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"
@@ -2982,6 +2987,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",
@@ -4178,6 +4184,7 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4451,6 +4458,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",
@@ -5298,6 +5306,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"
},
@@ -7901,6 +7910,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",
@@ -8533,6 +8543,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",
@@ -9847,6 +9858,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"
}
@@ -10126,6 +10138,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",
@@ -13808,6 +13821,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"
}
@@ -13818,6 +13832,7 @@
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"shell-quote": "^1.6.1",
"ws": "^7"
@@ -15906,6 +15921,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16129,7 +16145,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",
@@ -16137,6 +16154,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"
@@ -16296,6 +16314,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -16519,6 +16538,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",
@@ -16632,6 +16652,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16644,6 +16665,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",
@@ -17288,6 +17310,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"
}
@@ -17687,6 +17710,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
+8 -8
View File
@@ -31,18 +31,13 @@ const VALID_SANDBOX_COMMANDS: ReadonlyArray<SandboxConfig['command']> = [
'lxc',
];
function isSandboxCommand(value: string): value is SandboxConfig['command'] {
return (VALID_SANDBOX_COMMANDS as readonly string[]).includes(value);
function isSandboxCommand(value: string | undefined): value is SandboxConfig['command'] {
return (VALID_SANDBOX_COMMANDS as readonly (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']) {
return '';
}
// note environment variable takes precedence over argument (from command line or settings)
const environmentConfiguredSandbox =
process.env['GEMINI_SANDBOX']?.toLowerCase().trim() ?? '';
@@ -124,5 +119,10 @@ export async function loadSandboxConfig(
process.env['GEMINI_SANDBOX_IMAGE_DEFAULT'] ??
packageJson?.config?.sandboxImageUri;
return command && image ? { command, image } : undefined;
const enabled = !!(command === 'sandbox-exec' || (command && image));
if (enabled) {
console.error(`[DEBUG] Sandbox Enabled: ${command}${image ? ` (image: ${image})` : ''}`);
}
return enabled ? { command: command as SandboxConfig['command'], image, enabled: true } : undefined;
}
+8 -1
View File
@@ -234,6 +234,7 @@ vi.mock('./config/sandboxConfig.js', () => ({
loadSandboxConfig: vi.fn().mockResolvedValue({
command: 'docker',
image: 'test-image',
enabled: true,
}),
}));
@@ -618,13 +619,18 @@ describe('gemini.tsx main function kitty protocol', () => {
const mockConfig = createMockConfig({
isInteractive: () => false,
getQuestion: () => '',
getSandbox: () => ({ command: 'docker', image: 'test-image' }),
getSandbox: () => ({
command: 'docker',
image: 'test-image',
enabled: true,
}),
});
vi.mocked(loadCliConfig).mockResolvedValue(mockConfig);
vi.mocked(loadSandboxConfig).mockResolvedValue({
command: 'docker',
image: 'test-image',
enabled: true,
});
process.env['GEMINI_API_KEY'] = 'test-key';
@@ -914,6 +920,7 @@ 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({
enabled: true,
command: 'docker',
image: 'test-image',
});
+3 -63
View File
@@ -15,7 +15,6 @@ import { createHash } from 'node:crypto';
import v8 from 'node:v8';
import os from 'node:os';
import dns from 'node:dns';
import { start_sandbox } from './utils/sandbox.js';
import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js';
import {
loadTrustedFolders,
@@ -94,11 +93,6 @@ import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
import { KeypressProvider } from './ui/contexts/KeypressContext.js';
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
import {
relaunchAppInChildProcess,
relaunchOnExitCode,
} from './utils/relaunch.js';
import { loadSandboxConfig } from './config/sandboxConfig.js';
import { deleteSession, listSessions } from './utils/sessions.js';
import { createPolicyUpdater } from './config/policy.js';
import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
@@ -503,63 +497,9 @@ export async function main() {
// Run deferred command now that we have admin settings.
await runDeferredCommand(settings.merged);
// hop into sandbox if we are outside and sandboxing is enabled
if (!process.env['SANDBOX']) {
const memoryArgs = settings.merged.advanced.autoConfigureMemory
? getNodeMemoryArgs(isDebugMode)
: [];
const sandboxConfig = await loadSandboxConfig(settings.merged, argv);
// We intentionally omit the list of extensions here because extensions
// should not impact auth or setting up the sandbox.
// TODO(jacobr): refactor loadCliConfig so there is a minimal version
// that only initializes enough config to enable refreshAuth or find
// another way to decouple refreshAuth from requiring a config.
if (sandboxConfig) {
if (initialAuthFailed) {
await runExitCleanup();
process.exit(ExitCodes.FATAL_AUTHENTICATION_ERROR);
}
let stdinData = '';
if (!process.stdin.isTTY) {
stdinData = await readStdin();
}
// This function is a copy of the one from sandbox.ts
// It is moved here to decouple sandbox.ts from the CLI's argument structure.
const injectStdinIntoArgs = (
args: string[],
stdinData?: string,
): string[] => {
const finalArgs = [...args];
if (stdinData) {
const promptIndex = finalArgs.findIndex(
(arg) => arg === '--prompt' || arg === '-p',
);
if (promptIndex > -1 && finalArgs.length > promptIndex + 1) {
// If there's a prompt argument, prepend stdin to it
finalArgs[promptIndex + 1] =
`${stdinData}\n\n${finalArgs[promptIndex + 1]}`;
} else {
// If there's no prompt argument, add stdin as the prompt
finalArgs.push('--prompt', stdinData);
}
}
return finalArgs;
};
const sandboxArgs = injectStdinIntoArgs(process.argv, stdinData);
await relaunchOnExitCode(() =>
start_sandbox(sandboxConfig, memoryArgs, partialConfig, sandboxArgs),
);
await runExitCleanup();
process.exit(ExitCodes.SUCCESS);
} else {
// Relaunch app so we always have a child process that can be internally
// restarted if needed.
await relaunchAppInChildProcess(memoryArgs, [], remoteAdminSettings);
}
if (initialAuthFailed) {
await runExitCleanup();
process.exit(ExitCodes.FATAL_AUTHENTICATION_ERROR);
}
// We are now past the logic handling potentially launching a child process
+130 -1
View File
@@ -19,6 +19,8 @@ import {
useStdout,
useStdin,
type AppProps,
Box,
Text,
} from 'ink';
import { App } from './App.js';
import { AppContext } from './contexts/AppContext.js';
@@ -39,7 +41,7 @@ import {
} from './types.js';
import { checkPermissions } from './hooks/atCommandProcessor.js';
import { MessageType, StreamingState } from './types.js';
import { ToolActionsProvider } from './contexts/ToolActionsContext.js';
import { ToolActionsProvider, ToolActionsContext } from './contexts/ToolActionsContext.js';
import {
type StartupWarning,
type EditorType,
@@ -85,6 +87,8 @@ import {
buildUserSteeringHintPrompt,
logBillingEvent,
ApiKeyUpdatedEvent,
MessageBusType,
ToolConfirmationOutcome,
} from '@google/gemini-cli-core';
import { validateAuthMethod } from '../config/auth.js';
import process from 'node:process';
@@ -148,6 +152,10 @@ import { useSessionResume } from './hooks/useSessionResume.js';
import { useIncludeDirsTrust } from './hooks/useIncludeDirsTrust.js';
import { isWorkspaceTrusted } from '../config/trustedFolders.js';
import { useSettings } from './contexts/SettingsContext.js';
import { AskUserDialog } from './components/AskUserDialog.js';
import { ToolConfirmationMessage } from './components/messages/ToolConfirmationMessage.js';
import { StickyHeader } from './components/StickyHeader.js';
import { theme } from './semantic-colors.js';
import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js';
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
import { useBanner } from './hooks/useBanner.js';
@@ -1552,6 +1560,127 @@ Logging in with Google... Restarting Gemini CLI to continue.
useIncludeDirsTrust(config, isTrustedFolder, historyManager, setCustomDialog);
useEffect(() => {
const messageBus = config.getMessageBus();
const handler = (msg: any) => {
if (msg.type === MessageBusType.ASK_USER_REQUEST) {
setCustomDialog(
<AskUserDialog
questions={msg.questions}
width={terminalWidth}
availableHeight={terminalHeight}
onSubmit={(answers) => {
messageBus.publish({
type: MessageBusType.ASK_USER_RESPONSE,
correlationId: msg.correlationId,
answers,
cancelled: false,
});
setCustomDialog(null);
}}
onCancel={() => {
messageBus.publish({
type: MessageBusType.ASK_USER_RESPONSE,
correlationId: msg.correlationId,
answers: {},
cancelled: true,
});
setCustomDialog(null);
}}
/>
);
} else if (msg.type === MessageBusType.SANDBOX_EXPANSION_REQUEST) {
const customToolActions = {
confirm: async (callId: string, outcome: ToolConfirmationOutcome) => {
messageBus.publish({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: msg.correlationId,
confirmed: outcome !== ToolConfirmationOutcome.Cancel,
outcome,
});
setCustomDialog(null);
},
cancel: async (callId: string) => {
messageBus.publish({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: msg.correlationId,
confirmed: false,
outcome: ToolConfirmationOutcome.Cancel,
});
setCustomDialog(null);
},
isDiffingEnabled: false,
};
setCustomDialog(
<Box flexDirection="column" width={terminalWidth} flexShrink={0}>
<StickyHeader
width={terminalWidth}
isFirst={true}
borderColor={theme.status.warning}
borderDimColor={false}
>
<Box flexDirection="column" width={terminalWidth - 4}>
<Box
marginBottom={1}
justifyContent="space-between"
>
<Text color={theme.status.warning} bold>
Action Required
</Text>
</Box>
</Box>
</StickyHeader>
<Box
width={terminalWidth}
borderStyle="round"
borderColor={theme.status.warning}
borderTop={false}
borderBottom={false}
borderLeft={true}
borderRight={true}
paddingX={1}
flexDirection="column"
>
<ToolActionsContext.Provider value={customToolActions}>
<ToolConfirmationMessage
callId={msg.correlationId}
confirmationDetails={{
type: 'sandbox_expansion',
title: msg.requiresUnsandboxed ? 'Unrestricted Execution Required' : 'Sandbox Violation',
blockedPath: msg.requiresUnsandboxed ? `${msg.blockedPath} (Requires Unsandboxed Execution)` : msg.blockedPath,
}} config={config}
getPreferredEditor={() => undefined}
terminalWidth={terminalWidth - 4}
availableTerminalHeight={terminalHeight}
isFocused={true}
/>
</ToolActionsContext.Provider>
</Box>
<Box
height={1}
width={terminalWidth}
borderLeft={true}
borderRight={true}
borderTop={false}
borderBottom={true}
borderStyle="round"
borderColor={theme.status.warning}
borderDimColor={false}
/>
</Box>
);
}
};
messageBus.subscribe(MessageBusType.ASK_USER_REQUEST, handler);
messageBus.subscribe(MessageBusType.SANDBOX_EXPANSION_REQUEST, handler);
return () => {
messageBus.unsubscribe(MessageBusType.ASK_USER_REQUEST, handler);
messageBus.unsubscribe(MessageBusType.SANDBOX_EXPANSION_REQUEST, handler);
};
}, [config, setCustomDialog, terminalWidth, terminalHeight]);
const tabFocusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
@@ -6,15 +6,24 @@
import { describe, it, expect, vi } from 'vitest';
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
import type {
SerializableConfirmationDetails,
ToolCallConfirmationDetails,
Config,
import {
type SerializableConfirmationDetails,
type ToolCallConfirmationDetails,
type Config,
hasRedirection,
} from '@google/gemini-cli-core';
import { renderWithProviders } from '../../../test-utils/render.js';
import { createMockSettings } from '../../../test-utils/settings.js';
import { useToolActions } from '../../contexts/ToolActionsContext.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
hasRedirection: vi.fn(),
};
});
vi.mock('../../contexts/ToolActionsContext.js', async (importOriginal) => {
const actual =
await importOriginal<
@@ -240,6 +249,90 @@ describe('ToolConfirmationMessage', () => {
unmount();
});
it('should display redirection warning when hasRedirection is true in confirmationDetails', async () => {
const confirmationDetails: SerializableConfirmationDetails = {
type: 'exec',
title: 'Confirm Redirection',
command: 'ls > out.txt',
rootCommand: 'ls',
rootCommands: ['ls'],
hasRedirection: true,
};
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
getPreferredEditor={vi.fn()}
availableTerminalHeight={30}
terminalWidth={80}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Note: Command contains redirection which can be undesirable.');
unmount();
});
it('should display redirection warning via async fallback when hasRedirection is missing', async () => {
vi.mocked(hasRedirection).mockReturnValue(true);
const confirmationDetails: SerializableConfirmationDetails = {
type: 'exec',
title: 'Confirm Redirection Fallback',
command: 'ls > out.txt',
rootCommand: 'ls',
rootCommands: ['ls'],
// hasRedirection is missing
};
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
getPreferredEditor={vi.fn()}
availableTerminalHeight={30}
terminalWidth={80}
/>,
);
await waitUntilReady();
// The warning should be present as it is now calculated synchronously
const output = lastFrame();
expect(output).toContain('Note: Command contains redirection which can be undesirable.');
unmount();
});
it('should NOT display redirection warning when hasRedirection is false', async () => {
const confirmationDetails: SerializableConfirmationDetails = {
type: 'exec',
title: 'Confirm No Redirection',
command: 'ls -la',
rootCommand: 'ls',
rootCommands: ['ls'],
hasRedirection: false,
};
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
getPreferredEditor={vi.fn()}
availableTerminalHeight={30}
terminalWidth={80}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).not.toContain('Note: This command uses shell redirection');
unmount();
});
it('should render multiline shell scripts with correct newlines and syntax highlighting (SVG snapshot)', async () => {
const confirmationDetails: SerializableConfirmationDetails = {
type: 'exec',
@@ -90,6 +90,21 @@ export const ToolConfirmationMessage: React.FC<
confirmationDetails.type === 'exit_plan_mode';
const isTrustedFolder = config.isTrustedFolder();
const commandsToDisplay =
confirmationDetails.type === 'exec' &&
confirmationDetails.commands &&
confirmationDetails.commands.length > 1
? confirmationDetails.commands
: confirmationDetails.type === 'exec'
? [confirmationDetails.command]
: [];
const hasRedirectionCheck =
confirmationDetails.type === 'exec'
? confirmationDetails.hasRedirection ??
commandsToDisplay.some((cmd) => hasRedirection(cmd))
: false;
const handleConfirm = useCallback(
(outcome: ToolConfirmationOutcome, payload?: ToolConfirmationPayload) => {
void confirm(callId, outcome, payload).catch((error: unknown) => {
@@ -348,6 +363,24 @@ export const ToolConfirmationMessage: React.FC<
value: ToolConfirmationOutcome.Cancel,
key: 'No, suggest changes (esc)',
});
} else if (confirmationDetails.type === 'sandbox_expansion') {
options.push({
label: 'Allow once',
value: ToolConfirmationOutcome.ProceedOnce,
key: 'Allow once',
});
if (allowPermanentApproval) {
options.push({
label: 'Allow for all future sessions',
value: ToolConfirmationOutcome.ProceedAlwaysAndSave,
key: 'Allow for all future sessions',
});
}
options.push({
label: 'No, suggest changes (esc)',
value: ToolConfirmationOutcome.Cancel,
key: 'No, suggest changes (esc)',
});
}
return options;
}, [
@@ -469,6 +502,8 @@ export const ToolConfirmationMessage: React.FC<
// mcp tool confirmation
const mcpProps = confirmationDetails;
question = `Allow execution of MCP tool "${sanitizeForDisplay(mcpProps.toolName)}" from server "${sanitizeForDisplay(mcpProps.serverName)}"?`;
} else if (confirmationDetails.type === 'sandbox_expansion') {
question = `Sandbox blocked access to ${sanitizeForDisplay(confirmationDetails.blockedPath)}. Allow access?`;
}
if (confirmationDetails.type === 'edit') {
@@ -484,17 +519,14 @@ export const ToolConfirmationMessage: React.FC<
}
} else if (confirmationDetails.type === 'exec') {
const executionProps = confirmationDetails;
const containsRedirection = hasRedirectionCheck;
let bodyContentHeight = availableBodyContentHeight();
let warnings: React.ReactNode | null = null;
const commandsToDisplay =
executionProps.commands && executionProps.commands.length > 1
? executionProps.commands
: [executionProps.command];
const containsRedirection = commandsToDisplay.some((cmd) =>
hasRedirection(cmd),
);
let bodyContentHeight = availableBodyContentHeight();
let warnings: React.ReactNode = null;
if (bodyContentHeight !== undefined) {
bodyContentHeight -= 2; // Account for padding;
@@ -630,6 +662,17 @@ export const ToolConfirmationMessage: React.FC<
)}
</Box>
);
} else if (confirmationDetails.type === 'sandbox_expansion') {
bodyContent = (
<Box flexDirection="column">
<Text color={theme.text.primary}>
The sandbox prevented a command from accessing a file outside the workspace.
</Text>
<Box marginTop={1}>
<Text color={theme.text.link}>{sanitizeForDisplay(confirmationDetails.blockedPath)}</Text>
</Box>
</Box>
);
}
return { question, bodyContent, options, securityWarnings };
@@ -1,8 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="173" viewBox="0 0 920 173">
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="224" viewBox="0 0 920 224">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="173" fill="#000000" />
<rect width="920" height="224" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#00cdcd" textLength="36" lengthAdjust="spacingAndGlyphs">echo</text>
<text x="45" y="2" fill="#cdcd00" textLength="63" lengthAdjust="spacingAndGlyphs">&quot;hello&quot;</text>
@@ -14,17 +14,21 @@
<text x="18" y="36" fill="#00cdcd" textLength="36" lengthAdjust="spacingAndGlyphs">echo</text>
<text x="63" y="36" fill="#cd00cd" textLength="18" lengthAdjust="spacingAndGlyphs">$i</text>
<text x="0" y="53" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">done</text>
<text x="0" y="70" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Allow execution of: &apos;echo&apos;? </text>
<rect x="0" y="102" width="9" height="17" fill="#001a00" />
<text x="0" y="104" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="9" y="102" width="9" height="17" fill="#001a00" />
<rect x="18" y="102" width="18" height="17" fill="#001a00" />
<text x="18" y="104" fill="#00cd00" textLength="18" lengthAdjust="spacingAndGlyphs">1.</text>
<rect x="36" y="102" width="9" height="17" fill="#001a00" />
<rect x="45" y="102" width="90" height="17" fill="#001a00" />
<text x="45" y="104" fill="#00cd00" textLength="90" lengthAdjust="spacingAndGlyphs">Allow once</text>
<rect x="135" y="102" width="135" height="17" fill="#001a00" />
<text x="0" y="121" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> 2. Allow for this session </text>
<text x="0" y="138" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> 3. No, suggest changes (esc) </text>
<text x="0" y="87" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs" font-weight="bold">Note: </text>
<text x="54" y="87" fill="#ffffff" textLength="846" lengthAdjust="spacingAndGlyphs">Command contains redirection which can be undesirable. </text>
<text x="0" y="104" fill="#333333" textLength="54" lengthAdjust="spacingAndGlyphs" font-weight="bold">Tip: </text>
<text x="54" y="104" fill="#333333" textLength="576" lengthAdjust="spacingAndGlyphs">Toggle auto-edit (Shift+Tab) to allow redirection in the future.</text>
<text x="0" y="121" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Allow execution of: &apos;echo&apos;? </text>
<rect x="0" y="153" width="9" height="17" fill="#001a00" />
<text x="0" y="155" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="9" y="153" width="9" height="17" fill="#001a00" />
<rect x="18" y="153" width="18" height="17" fill="#001a00" />
<text x="18" y="155" fill="#00cd00" textLength="18" lengthAdjust="spacingAndGlyphs">1.</text>
<rect x="36" y="153" width="9" height="17" fill="#001a00" />
<rect x="45" y="153" width="90" height="17" fill="#001a00" />
<text x="45" y="155" fill="#00cd00" textLength="90" lengthAdjust="spacingAndGlyphs">Allow once</text>
<rect x="135" y="153" width="135" height="17" fill="#001a00" />
<text x="0" y="172" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> 2. Allow for this session </text>
<text x="0" y="189" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> 3. No, suggest changes (esc) </text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

@@ -42,6 +42,9 @@ exports[`ToolConfirmationMessage > should render multiline shell scripts with co
for i in 1 2 3; do
echo $i
done
Note: Command contains redirection which can be undesirable.
Tip: Toggle auto-edit (Shift+Tab) to allow redirection in the future.
Allow execution of: 'echo'?
● 1. Allow once
@@ -93,6 +96,9 @@ Apply this change?
exports[`ToolConfirmationMessage > with folder trust > 'for exec confirmations' > should NOT show "allow always" when folder is untrusted 1`] = `
"echo "hello"
Note: Command contains redirection which can be undesirable.
Tip: Toggle auto-edit (Shift+Tab) to allow redirection in the future.
Allow execution of: 'echo'?
● 1. Allow once
@@ -102,6 +108,9 @@ Allow execution of: 'echo'?
exports[`ToolConfirmationMessage > with folder trust > 'for exec confirmations' > should show "allow always" when folder is trusted 1`] = `
"echo "hello"
Note: Command contains redirection which can be undesirable.
Tip: Toggle auto-edit (Shift+Tab) to allow redirection in the future.
Allow execution of: 'echo'?
● 1. Allow once
@@ -50,7 +50,7 @@ interface ToolActionsContextValue {
isDiffingEnabled: boolean;
}
const ToolActionsContext = createContext<ToolActionsContextValue | null>(null);
export const ToolActionsContext = createContext<ToolActionsContextValue | null>(null);
export const useToolActions = () => {
const context = useContext(ToolActionsContext);
@@ -76,6 +76,7 @@ import {
type ShellExecutionResult,
type ShellOutputEvent,
CoreToolCallStatus,
SandboxProfile,
} from '@google/gemini-cli-core';
import * as fs from 'node:fs';
import * as os from 'node:os';
@@ -214,6 +215,7 @@ describe('useShellCommandProcessor', () => {
expect.any(Object),
false,
expect.any(Object),
SandboxProfile.WORKSPACE_WRITE,
);
expect(onExecMock).toHaveBeenCalledWith(expect.any(Promise));
});
@@ -306,6 +308,7 @@ describe('useShellCommandProcessor', () => {
expect.any(Object),
false, // enableInteractiveShell
expect.any(Object),
SandboxProfile.WORKSPACE_WRITE,
);
// Wait for the async PID update to happen.
@@ -430,6 +433,7 @@ describe('useShellCommandProcessor', () => {
expect.any(Object),
false,
expect.any(Object),
SandboxProfile.WORKSPACE_WRITE,
);
await act(async () => {
@@ -65,7 +65,7 @@ describe('useTurnActivityMonitor', () => {
expect(result.current.operationStartTime).toBe(2000);
});
it('should detect redirection from tool calls', () => {
it('should detect redirection from tool calls', async () => {
// Force mock implementation to ensure it's active
vi.mocked(hasRedirection).mockImplementation((q: string) =>
q.includes('>'),
@@ -48,19 +48,14 @@ export const useTurnActivityMonitor = (
}, [streamingState, activePtyId]);
// Detect redirection in the current query or tool calls.
// We derive this directly during render to ensure it's accurate from the first frame.
const isRedirectionActive = useMemo(
() =>
// Check active tool calls for run_shell_command
pendingToolCalls.some((tc) => {
if (tc.request.name !== 'run_shell_command') return false;
const isRedirectionActive = useMemo(() => {
return pendingToolCalls.some((tc) => {
if (tc.request.name !== 'run_shell_command') return false;
const command =
(tc.request.args as { command?: string })?.command || '';
return hasRedirection(command);
}),
[pendingToolCalls],
);
const command = (tc.request.args as { command?: string })?.command || '';
return hasRedirection(command);
});
}, [pendingToolCalls]);
return {
operationStartTime,
+13 -13
View File
@@ -137,7 +137,7 @@ 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 = { enabled: true,
command: 'sandbox-exec',
image: 'some-image',
};
@@ -173,7 +173,7 @@ 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 = { enabled: true,
command: 'sandbox-exec',
image: 'some-image',
};
@@ -182,7 +182,7 @@ describe('sandbox', () => {
});
it('should handle Docker execution', async () => {
const config: SandboxConfig = {
const config: SandboxConfig = { enabled: true,
command: 'docker',
image: 'gemini-cli-sandbox',
};
@@ -231,7 +231,7 @@ describe('sandbox', () => {
});
it('should pull image if missing', async () => {
const config: SandboxConfig = {
const config: SandboxConfig = { enabled: true,
command: 'docker',
image: 'missing-image',
};
@@ -300,7 +300,7 @@ describe('sandbox', () => {
});
it('should throw if image pull fails', async () => {
const config: SandboxConfig = {
const config: SandboxConfig = { enabled: true,
command: 'docker',
image: 'missing-image',
};
@@ -338,7 +338,7 @@ describe('sandbox', () => {
});
it('should mount volumes correctly', async () => {
const config: SandboxConfig = {
const config: SandboxConfig = { enabled: true,
command: 'docker',
image: 'gemini-cli-sandbox',
};
@@ -395,7 +395,7 @@ describe('sandbox', () => {
});
it('should pass through GOOGLE_GEMINI_BASE_URL and GOOGLE_VERTEX_BASE_URL', async () => {
const config: SandboxConfig = {
const config: SandboxConfig = { enabled: true,
command: 'docker',
image: 'gemini-cli-sandbox',
};
@@ -442,7 +442,7 @@ describe('sandbox', () => {
});
it('should handle user creation on Linux if needed', async () => {
const config: SandboxConfig = {
const config: SandboxConfig = { enabled: true,
command: 'docker',
image: 'gemini-cli-sandbox',
};
@@ -508,7 +508,7 @@ 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 = { enabled: true,
command: 'lxc',
image: 'gemini-sandbox',
};
@@ -542,7 +542,7 @@ describe('sandbox', () => {
it('should throw FatalSandboxError if lxc list fails', async () => {
process.env['TEST_LXC_LIST_OUTPUT'] = 'throw';
const config: SandboxConfig = {
const config: SandboxConfig = { enabled: true,
command: 'lxc',
image: 'gemini-sandbox',
};
@@ -554,7 +554,7 @@ 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 = { enabled: true,
command: 'lxc',
image: 'gemini-sandbox',
};
@@ -564,7 +564,7 @@ describe('sandbox', () => {
it('should throw FatalSandboxError if container is not found in list', async () => {
process.env['TEST_LXC_LIST_OUTPUT'] = '[]';
const config: SandboxConfig = {
const config: SandboxConfig = { enabled: true,
command: 'lxc',
image: 'gemini-sandbox',
};
@@ -577,7 +577,7 @@ 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 = { enabled: true,
command: 'runsc',
image: 'gemini-cli-sandbox',
};
+13 -2
View File
@@ -268,6 +268,13 @@ export async function start_sandbox(
}
}
// stop if command or image is missing
if (!command || !image) {
throw new FatalSandboxError(
'Sandbox configuration is missing required "command" or "image" field.',
);
}
// stop if image is missing
if (!(await ensureSandboxImageIsPresent(command, image, cliConfig))) {
const remedy =
@@ -714,10 +721,10 @@ export async function start_sandbox(
// proxyProcess.stdout?.on('data', (data) => {
// console.info(data.toString());
// });
proxyProcess.stderr?.on('data', (data) => {
proxyProcess?.stderr?.on('data', (data) => {
debugLogger.debug(`[PROXY STDERR]: ${data.toString().trim()}`);
});
proxyProcess.on('close', (code, signal) => {
proxyProcess?.on('close', (code, signal) => {
if (sandboxProcess?.pid) {
process.kill(-sandboxProcess.pid, 'SIGTERM');
}
@@ -743,6 +750,10 @@ export async function start_sandbox(
});
return await new Promise<number>((resolve, reject) => {
if (!sandboxProcess) {
reject(new Error('Failed to spawn sandbox process.'));
return;
}
sandboxProcess.on('error', (err) => {
coreEvents.emitFeedback('error', 'Sandbox process error', err);
reject(err);
@@ -42,6 +42,21 @@ vi.mock('../../utils/debugLogger.js', () => ({
},
}));
vi.mock('../../utils/shell-utils.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../utils/shell-utils.js')>();
return {
...actual,
prepareSandboxedCommand: vi.fn().mockImplementation((cmd: string, args: string[]) =>
Promise.resolve({
program: cmd,
args,
cleanup: vi.fn(),
}),
),
};
});
import { prepareSandboxedCommand } from '../../utils/shell-utils.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
@@ -65,6 +80,14 @@ describe('BrowserManager', () => {
},
});
vi.mocked(prepareSandboxedCommand).mockImplementation((cmd: string, args: string[]) =>
Promise.resolve({
program: cmd,
args,
cleanup: vi.fn(),
}),
);
// Re-setup Client mock after reset
vi.mocked(Client).mockImplementation(
() =>
@@ -262,6 +285,30 @@ describe('BrowserManager', () => {
expect(args).not.toContain('--isolated');
});
it('should apply sandboxing to stdio transport', async () => {
const mockCleanup = vi.fn();
vi.mocked(prepareSandboxedCommand).mockResolvedValue({
program: 'sandboxed-npx',
args: ['sandboxed-arg'],
cleanup: mockCleanup,
});
const manager = new BrowserManager(mockConfig);
await manager.ensureConnection();
expect(prepareSandboxedCommand).toHaveBeenCalledWith(
process.platform === 'win32' ? 'npx.cmd' : 'npx',
expect.anything(),
);
expect(StdioClientTransport).toHaveBeenCalledWith(
expect.objectContaining({
command: 'sandboxed-npx',
args: ['sandboxed-arg'],
}),
);
});
it('should throw actionable error when existing mode connection fails', async () => {
// Make the Client mock's connect method reject
vi.mocked(Client).mockImplementation(
@@ -19,6 +19,9 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import { SandboxedTransport } from '../../tools/sandboxed-transport.js';
import { prepareSandboxedCommand } from '../../utils/shell-utils.js';
import type { Tool as McpTool } from '@modelcontextprotocol/sdk/types.js';
import { debugLogger } from '../../utils/debugLogger.js';
import type { Config } from '../../config/config.js';
@@ -67,7 +70,7 @@ export interface McpToolCallResult {
export class BrowserManager {
// Raw MCP SDK Client - NOT the wrapper McpClient
private rawMcpClient: Client | undefined;
private mcpTransport: StdioClientTransport | undefined;
private mcpTransport: Transport | undefined;
private discoveredTools: McpTool[] = [];
constructor(private config: Config) {}
@@ -282,14 +285,21 @@ export class BrowserManager {
// Create stdio transport to npx chrome-devtools-mcp.
// stderr is piped (not inherited) to prevent MCP server banners and
// warnings from corrupting the UI in alternate buffer mode.
this.mcpTransport = new StdioClientTransport({
command: process.platform === 'win32' ? 'npx.cmd' : 'npx',
args: mcpArgs,
const command = process.platform === 'win32' ? 'npx.cmd' : 'npx';
const {
program: sandboxedCommand,
args: sandboxedArgs,
cleanup,
} = await prepareSandboxedCommand(command, mcpArgs);
const stdioTransport = new StdioClientTransport({
command: sandboxedCommand,
args: sandboxedArgs,
stderr: 'pipe',
});
// Forward piped stderr to debugLogger so it's visible with --debug.
const stderrStream = this.mcpTransport.stderr;
const stderrStream = stdioTransport.stderr;
if (stderrStream) {
stderrStream.on('data', (chunk: Buffer) => {
debugLogger.log(
@@ -298,6 +308,12 @@ export class BrowserManager {
});
}
if (cleanup) {
this.mcpTransport = new SandboxedTransport(stdioTransport, cleanup);
} else {
this.mcpTransport = stdioTransport;
}
this.mcpTransport.onclose = () => {
debugLogger.error(
'chrome-devtools-mcp transport closed unexpectedly. ' +
+6
View File
@@ -248,6 +248,7 @@ vi.mock('../code_assist/experiments/experiments.js');
describe('Server Config (config.ts)', () => {
const MODEL = DEFAULT_GEMINI_MODEL;
const SANDBOX: SandboxConfig = {
enabled: true,
command: 'docker',
image: 'gemini-cli-sandbox',
};
@@ -1480,6 +1481,7 @@ describe('Server Config (config.ts)', () => {
describe('GemmaModelRouterSettings', () => {
const MODEL = DEFAULT_GEMINI_MODEL;
const SANDBOX: SandboxConfig = {
enabled: true,
command: 'docker',
image: 'gemini-cli-sandbox',
};
@@ -1860,6 +1862,7 @@ describe('isYoloModeDisabled', () => {
describe('BaseLlmClient Lifecycle', () => {
const MODEL = 'gemini-pro';
const SANDBOX: SandboxConfig = {
enabled: true,
command: 'docker',
image: 'gemini-cli-sandbox',
};
@@ -1915,6 +1918,7 @@ describe('BaseLlmClient Lifecycle', () => {
describe('Generation Config Merging (HACK)', () => {
const MODEL = 'gemini-pro';
const SANDBOX: SandboxConfig = {
enabled: true,
command: 'docker',
image: 'gemini-cli-sandbox',
};
@@ -2221,6 +2225,7 @@ describe('Config getHooks', () => {
describe('LocalLiteRtLmClient Lifecycle', () => {
const MODEL = 'gemini-pro';
const SANDBOX: SandboxConfig = {
enabled: true,
command: 'docker',
image: 'gemini-cli-sandbox',
};
@@ -2479,6 +2484,7 @@ describe('Config Quota & Preview Model Access', () => {
usageStatisticsEnabled: false,
embeddingModel: 'gemini-embedding',
sandbox: {
enabled: true,
command: 'docker',
image: 'gemini-cli-sandbox',
},
+31 -4
View File
@@ -105,14 +105,22 @@ import {
import { DEFAULT_MODEL_CONFIGS } from './defaultModelConfigs.js';
import { ContextManager } from '../services/contextManager.js';
import { TrackerService } from '../services/trackerService.js';
import {
type SandboxManager,
StandardSandboxManager,
} from '../services/sandboxManager.js';
import type { GenerateContentParameters } from '@google/genai';
// Re-export OAuth config type
export type { MCPOAuthConfig, AnyToolInvocation, AnyDeclarativeTool };
import type { AnyToolInvocation, AnyDeclarativeTool } from '../tools/tools.js';
import { setSandboxManager as setCentralSandboxManager } from '../utils/shell-utils.js';
import { WorkspaceContext } from '../utils/workspaceContext.js';
import { Storage } from './storage.js';
import type { ShellExecutionConfig } from '../services/shellExecutionService.js';
import {
ShellExecutionService,
type ShellExecutionConfig,
} from '../services/shellExecutionService.js';
import { FileExclusions } from '../utils/ignorePatterns.js';
import { MessageBus } from '../confirmation-bus/message-bus.js';
import type { EventEmitter } from 'node:events';
@@ -446,8 +454,10 @@ export enum AuthProviderType {
}
export interface SandboxConfig {
command: 'docker' | 'podman' | 'sandbox-exec' | 'runsc' | 'lxc';
image: string;
enabled: boolean;
command?: 'docker' | 'podman' | 'sandbox-exec' | 'runsc' | 'lxc';
image?: string;
allowedPaths?: string[];
}
/**
@@ -798,6 +808,7 @@ export class Config implements McpContext {
private readonly planModeRoutingEnabled: boolean;
private readonly modelSteering: boolean;
private contextManager?: ContextManager;
private sandboxManager!: SandboxManager;
private terminalBackground: string | undefined = undefined;
private remoteAdminSettings: AdminControlsSettings | undefined;
private latestApiRequest: GenerateContentParameters | undefined;
@@ -813,6 +824,9 @@ export class Config implements McpContext {
params.embeddingModel ?? DEFAULT_GEMINI_EMBEDDING_MODEL;
this.fileSystemService = new StandardFileSystemService();
this.sandbox = params.sandbox;
this.sandboxManager = new StandardSandboxManager(this);
ShellExecutionService.setSandboxManager(this.sandboxManager);
setCentralSandboxManager(this.sandboxManager);
this.targetDir = path.resolve(params.targetDir);
this.folderTrust = params.folderTrust ?? false;
this.workspaceContext = new WorkspaceContext(this.targetDir, []);
@@ -1561,6 +1575,10 @@ export class Config implements McpContext {
return this.sandbox;
}
getSandboxManager(): SandboxManager {
return this.sandboxManager;
}
isRestrictiveSandbox(): boolean {
const sandboxConfig = this.getSandbox();
const seatbeltProfile = process.env['SEATBELT_PROFILE'];
@@ -2388,7 +2406,16 @@ export class Config implements McpContext {
const projectTempDir = this.storage.getProjectTempDir();
const resolvedTempDir = realpath(projectTempDir);
return isSubpath(resolvedTempDir, resolvedPath);
if (isSubpath(resolvedTempDir, resolvedPath)) {
return true;
}
const sandboxConfig = this.getSandbox();
if (sandboxConfig?.allowedPaths?.some(p => isSubpath(realpath(p), resolvedPath))) {
return true;
}
return false;
}
/**
@@ -21,6 +21,8 @@ export enum MessageBusType {
TOOL_CALLS_UPDATE = 'tool-calls-update',
ASK_USER_REQUEST = 'ask-user-request',
ASK_USER_RESPONSE = 'ask-user-response',
SANDBOX_EXPANSION_REQUEST = 'sandbox-expansion-request',
SANDBOX_EXPANSION_RESPONSE = 'sandbox-expansion-response',
}
export interface ToolCallsUpdateMessage {
@@ -92,6 +94,7 @@ export type SerializableConfirmationDetails =
rootCommand: string;
rootCommands: string[];
commands?: string[];
hasRedirection?: boolean;
}
| {
type: 'mcp';
@@ -112,6 +115,11 @@ export type SerializableConfirmationDetails =
type: 'exit_plan_mode';
title: string;
planPath: string;
}
| {
type: 'sandbox_expansion';
title: string;
blockedPath: string;
};
export interface UpdatePolicy {
@@ -178,6 +186,19 @@ export interface AskUserResponse {
cancelled?: boolean;
}
export interface SandboxExpansionRequest {
type: MessageBusType.SANDBOX_EXPANSION_REQUEST;
correlationId: string;
blockedPath: string;
requiresUnsandboxed?: boolean;
}
export interface SandboxExpansionResponse {
type: MessageBusType.SANDBOX_EXPANSION_RESPONSE;
correlationId: string;
decision: 'Allow Once' | 'Always Allow' | 'Deny';
}
export type Message =
| ToolConfirmationRequest
| ToolConfirmationResponse
@@ -187,4 +208,6 @@ export type Message =
| UpdatePolicy
| AskUserRequest
| AskUserResponse
| SandboxExpansionRequest
| SandboxExpansionResponse
| ToolCallsUpdateMessage;
+60 -2
View File
@@ -14,6 +14,7 @@ import {
type Mocked,
} from 'vitest';
import { IdeClient, IDEConnectionStatus } from './ide-client.js';
import { SandboxedTransport } from '../tools/sandboxed-transport.js';
import type * as fs from 'node:fs';
import { getIdeProcessInfo } from './process-utils.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
@@ -50,6 +51,20 @@ vi.mock('@modelcontextprotocol/sdk/client/stdio.js');
vi.mock('./detect-ide.js');
vi.mock('node:os');
vi.mock('./ide-connection-utils.js');
vi.mock('../utils/shell-utils.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../utils/shell-utils.js')>();
return {
...actual,
prepareSandboxedCommand: vi.fn().mockImplementation((cmd: string, args: string[]) =>
Promise.resolve({
program: cmd,
args,
cleanup: vi.fn(),
}),
),
};
});
import { prepareSandboxedCommand } from '../utils/shell-utils.js';
describe('IdeClient', () => {
let mockClient: Mocked<Client>;
@@ -97,6 +112,14 @@ describe('IdeClient', () => {
vi.mocked(StreamableHTTPClientTransport).mockReturnValue(mockHttpTransport);
vi.mocked(StdioClientTransport).mockReturnValue(mockStdioTransport);
vi.mocked(prepareSandboxedCommand).mockImplementation((cmd: string, args: string[]) =>
Promise.resolve({
program: cmd,
args,
cleanup: vi.fn(),
}),
);
await IdeClient.getInstance();
});
@@ -137,7 +160,9 @@ describe('IdeClient', () => {
command: 'test-cmd',
args: ['--foo'],
});
expect(mockClient.connect).toHaveBeenCalledWith(mockStdioTransport);
expect(mockClient.connect).toHaveBeenCalledWith(
expect.any(SandboxedTransport),
);
expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected,
);
@@ -194,12 +219,45 @@ describe('IdeClient', () => {
command: 'env-cmd',
args: ['--bar'],
});
expect(mockClient.connect).toHaveBeenCalledWith(mockStdioTransport);
expect(mockClient.connect).toHaveBeenCalledWith(
expect.any(SandboxedTransport),
);
expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected,
);
});
it('should apply sandboxing to stdio transport', async () => {
vi.mocked(getConnectionConfigFromFile).mockResolvedValue(undefined);
vi.mocked(validateWorkspacePath).mockReturnValue({ isValid: true });
vi.mocked(getStdioConfigFromEnv).mockReturnValue({
command: 'original-command',
args: ['--original-arg'],
});
const mockCleanup = vi.fn();
vi.mocked(prepareSandboxedCommand).mockResolvedValue({
program: 'sandboxed-command',
args: ['--sandboxed-arg'],
cleanup: mockCleanup,
});
const ideClient = await IdeClient.getInstance();
await ideClient.connect();
expect(prepareSandboxedCommand).toHaveBeenCalledWith(
'original-command',
['--original-arg'],
);
expect(StdioClientTransport).toHaveBeenCalledWith(
expect.objectContaining({
command: 'sandboxed-command',
args: ['--sandboxed-arg'],
}),
);
});
it('should prioritize file config over environment variables', async () => {
const config = { port: '8080' };
vi.mocked(getConnectionConfigFromFile).mockResolvedValue(config);
+16 -3
View File
@@ -16,6 +16,9 @@ import { getIdeProcessInfo } from './process-utils.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import { SandboxedTransport } from '../tools/sandboxed-transport.js';
import { prepareSandboxedCommand } from '../utils/shell-utils.js';
import {
CallToolResultSchema,
ListToolsResultSchema,
@@ -618,7 +621,7 @@ export class IdeClient {
command,
args,
}: StdioConfig): Promise<boolean> {
let transport: StdioClientTransport | undefined;
let transport: Transport | undefined;
try {
logger.debug('Attempting to connect to IDE via stdio');
this.client = new Client({
@@ -627,10 +630,20 @@ export class IdeClient {
version: '1.0.0',
});
const {
program: sandboxedCommand,
args: sandboxedArgs,
cleanup,
} = await prepareSandboxedCommand(command, args);
transport = new StdioClientTransport({
command,
args,
command: sandboxedCommand,
args: sandboxedArgs,
});
if (cleanup) {
transport = new SandboxedTransport(transport, cleanup);
}
await this.client.connect(transport);
this.registerClientHandlers();
await this.discoverTools();
+1
View File
@@ -123,6 +123,7 @@ export * from './services/sessionSummaryUtils.js';
export * from './services/contextManager.js';
export * from './services/trackerService.js';
export * from './services/trackerTypes.js';
export * from './services/sandboxManager.js';
export * from './skills/skillManager.js';
export * from './skills/skillLoader.js';
+5 -5
View File
@@ -197,10 +197,10 @@ export class PolicyEngine {
return this.approvalMode;
}
private shouldDowngradeForRedirection(
private async shouldDowngradeForRedirection(
command: string,
allowRedirection?: boolean,
): boolean {
): Promise<boolean> {
return (
!allowRedirection &&
hasRedirection(command) &&
@@ -274,7 +274,7 @@ export class PolicyEngine {
let responsibleRule: PolicyRule | undefined;
// Check for redirection on the full command string
if (this.shouldDowngradeForRedirection(command, allowRedirection)) {
if (await this.shouldDowngradeForRedirection(command, allowRedirection)) {
debugLogger.debug(
`[PolicyEngine.check] Downgrading ALLOW to ASK_USER for redirected command: ${command}`,
);
@@ -286,7 +286,7 @@ export class PolicyEngine {
const subCmd = rawSubCmd.trim();
// Prevent infinite recursion for the root command
if (subCmd === command) {
if (this.shouldDowngradeForRedirection(subCmd, allowRedirection)) {
if (await this.shouldDowngradeForRedirection(subCmd, allowRedirection)) {
debugLogger.debug(
`[PolicyEngine.check] Downgrading ALLOW to ASK_USER for redirected command: ${subCmd}`,
);
@@ -336,7 +336,7 @@ export class PolicyEngine {
// Check for redirection in allowed sub-commands
if (
subDecision === PolicyDecision.ALLOW &&
this.shouldDowngradeForRedirection(subCmd, allowRedirection)
(await this.shouldDowngradeForRedirection(subCmd, allowRedirection))
) {
debugLogger.debug(
`[PolicyEngine.check] Downgrading ALLOW to ASK_USER for redirected command: ${subCmd}`,
@@ -71,6 +71,7 @@ vi.mock('../utils/shell-utils.js', async (importOriginal) => {
hasRedirection: (command: string) =>
// Simple regex check sufficient for testing the policy engine's handling of the *result* of hasRedirection
/[><]/.test(command),
};
});
@@ -0,0 +1,131 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import toml from '@iarna/toml';
import { SandboxProfile } from './sandboxManager.js';
import { debugLogger } from '../utils/debugLogger.js';
export interface ExecPolicyRule {
prefix: string[];
profile: SandboxProfile;
}
export class ExecPolicyEngine {
private rules: ExecPolicyRule[] = [];
constructor(private readonly configDir: string) {
this.loadPolicy();
}
loadPolicy() {
this.rules = [];
const policyPath = path.join(this.configDir, 'execpolicy.toml');
if (fs.existsSync(policyPath)) {
try {
const content = fs.readFileSync(policyPath, 'utf8');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const parsed = toml.parse(content) as any;
if (parsed && Array.isArray(parsed.rules)) {
for (const rule of parsed.rules) {
if (Array.isArray(rule.prefix) && typeof rule.profile === 'string') {
const profile =
rule.profile === 'WorkspaceWrite'
? SandboxProfile.WORKSPACE_WRITE
: SandboxProfile.READ_ONLY;
this.rules.push({
prefix: rule.prefix,
profile,
});
}
}
}
} catch (_error) {
debugLogger.error('Failed to parse execpolicy.toml:', _error);
}
}
}
getProfileForCommand(commandStr: string): SandboxProfile {
// Basic tokenization by space.
// In a real shell parser, this would handle quotes, but this is a prototype.
const tokens = commandStr.trim().split(/\s+/);
let bestMatchLength = -1;
let bestMatchProfile = SandboxProfile.READ_ONLY; // Default
for (const rule of this.rules) {
if (rule.prefix.length > tokens.length) {
continue;
}
let match = true;
for (let i = 0; i < rule.prefix.length; i++) {
if (rule.prefix[i] !== tokens[i]) {
match = false;
break;
}
}
if (match && rule.prefix.length > bestMatchLength) {
bestMatchLength = rule.prefix.length;
bestMatchProfile = rule.profile;
}
}
return bestMatchProfile;
}
public async addRule(prefix: string[], profile: SandboxProfile) {
this.rules.push({ prefix, profile });
const policyPath = path.join(this.configDir, 'execpolicy.toml');
let parsed: { rules: any[] } = { rules: [] };
if (fs.existsSync(policyPath)) {
try {
const content = fs.readFileSync(policyPath, 'utf8');
const tomlParsed = toml.parse(content);
parsed = tomlParsed as unknown as { rules: any[] };
if (!Array.isArray(parsed.rules)) {
parsed.rules = [];
}
} catch (_error) {
// Ignore parse errors, just overwrite
}
}
const profileStr =
profile === SandboxProfile.WORKSPACE_WRITE
? 'WorkspaceWrite'
: 'ReadOnly';
// Check if the rule already exists to avoid duplicates
let exists = false;
for (const r of parsed.rules) {
if (Array.isArray(r.prefix) && r.prefix.length === prefix.length && r.prefix.every((val: string, index: number) => val === prefix[index])) {
r.profile = profileStr;
exists = true;
break;
}
}
if (!exists) {
parsed.rules.push({
prefix,
profile: profileStr,
});
}
if (!fs.existsSync(this.configDir)) {
fs.mkdirSync(this.configDir, { recursive: true });
}
fs.writeFileSync(policyPath, toml.stringify(parsed as any));
}
}
@@ -0,0 +1,148 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { StandardSandboxManager, SandboxProfile } from './sandboxManager.js';
import type { Config } from '../config/config.js';
import * as fs from 'node:fs';
vi.mock('node:fs', async () => {
const actual = await vi.importActual('node:fs');
return {
...actual,
mkdtempSync: vi.fn().mockReturnValue('/tmp/gemini-sandbox-123'),
writeFileSync: vi.fn(),
};
});
describe('StandardSandboxManager', () => {
const mockConfig = {
getSandbox: vi.fn(),
} as unknown as Config;
const manager = new StandardSandboxManager(mockConfig);
const originalPlatform = process.platform;
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.stubGlobal('process', { ...process, platform: originalPlatform });
});
it('should return original command when sandbox is disabled', async () => {
vi.mocked(mockConfig.getSandbox).mockReturnValue({ enabled: false });
const result = await manager.prepareCommand({
command: 'ls',
args: ['-la'],
cwd: '/tmp',
});
expect(result).toEqual({
program: 'ls',
args: ['-la'],
});
});
it('should return original command when sandbox is undefined', async () => {
vi.mocked(mockConfig.getSandbox).mockReturnValue(undefined);
const result = await manager.prepareCommand({
command: 'ls',
args: ['-la'],
cwd: '/tmp',
});
expect(result).toEqual({
program: 'ls',
args: ['-la'],
});
});
it('should return sandboxed command on macOS when enabled', async () => {
vi.stubGlobal('process', { ...process, platform: 'darwin' });
vi.mocked(mockConfig.getSandbox).mockReturnValue({ enabled: true, allowedPaths: ['/extra/path'] });
const result = await manager.prepareCommand({
command: 'ls',
args: ['-la'],
cwd: '/my/workspace',
});
expect(result.program).toBe('/usr/bin/sandbox-exec');
expect(result.args[0]).toBe('-f');
expect(result.args[1]).toContain('sandbox.sb');
expect(result.args[2]).toBe('ls');
expect(result.args[3]).toBe('-la');
// Verify profile content
const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0];
const profileContent = writeCall[1] as string;
expect(profileContent).toContain('(version 1)');
expect(profileContent).toContain('(deny default)');
expect(profileContent).toContain('(allow pseudo-tty)');
expect(profileContent).toContain('(subpath "/my/workspace")');
expect(profileContent).toContain('(subpath "/extra/path")');
expect(profileContent).toContain('(allow file-read* file-write* (subpath "/my/workspace"))');
});
it('should use READ_ONLY profile when specified', async () => {
vi.stubGlobal('process', { ...process, platform: 'darwin' });
vi.mocked(mockConfig.getSandbox).mockReturnValue({ enabled: true });
await manager.prepareCommand({
command: 'grep',
args: ['pattern'],
cwd: '/my/workspace',
profile: SandboxProfile.READ_ONLY,
});
const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0];
const profileContent = writeCall[1] as string;
expect(profileContent).toContain('(allow file-read* (subpath "/my/workspace"))');
expect(profileContent).not.toContain('(allow file-read* file-write* (subpath "/my/workspace"))');
});
it('should use WORKSPACE_WRITE profile when explicitly specified', async () => {
vi.stubGlobal('process', { ...process, platform: 'darwin' });
vi.mocked(mockConfig.getSandbox).mockReturnValue({ enabled: true });
await manager.prepareCommand({
command: 'ls',
args: ['-la'],
cwd: '/my/workspace',
profile: SandboxProfile.WORKSPACE_WRITE,
});
const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0];
const profileContent = writeCall[1] as string;
expect(profileContent).toContain('(allow file-read* file-write* (subpath "/my/workspace"))');
});
it('should fallback to original command if sandbox setup fails', async () => {
vi.stubGlobal('process', { ...process, platform: 'darwin' });
vi.mocked(mockConfig.getSandbox).mockReturnValue({ enabled: true });
vi.mocked(fs.writeFileSync).mockImplementation(() => {
throw new Error('Disk full');
});
const result = await manager.prepareCommand({
command: 'ls',
args: ['-la'],
cwd: '/tmp',
});
expect(result).toEqual({
program: 'ls',
args: ['-la'],
});
});
});
@@ -0,0 +1,249 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import toml from '@iarna/toml';
import type { Config } from '../config/config.js';
import { debugLogger } from '../utils/debugLogger.js';
/**
* Defines different levels of access for the sandbox.
*/
export enum SandboxProfile {
/**
* Only allows read access to the workspace and specified paths.
*/
READ_ONLY = 'READ_ONLY',
/**
* Allows both read and write access to the workspace and specified paths.
*/
WORKSPACE_WRITE = 'WORKSPACE_WRITE',
/**
* Completely bypasses the sandbox.
*/
UNSANDBOXED = 'UNSANDBOXED',
}
/**
* Represents a command that has been modified to run in a sandbox.
*/
export interface SandboxedCommand {
/**
* The program to execute (may be a sandbox utility like 'sandbox-exec').
*/
program: string;
/**
* The arguments to pass to the program.
*/
args: string[];
/**
* Optional cleanup function to be called after the command finishes.
*/
cleanup?: () => void;
}
/**
* Options for preparing a sandboxed command.
*/
export interface SandboxOptions {
command: string;
args: string[];
cwd: string;
env?: NodeJS.ProcessEnv;
/**
* The profile to use for sandboxing (e.g., READ_ONLY, WORKSPACE_WRITE).
* Defaults to WORKSPACE_WRITE if not specified.
*/
profile?: SandboxProfile;
ephemeralRules?: string[];
}
/**
* Manages the preparation and execution of sandboxed commands.
*/
export interface SandboxManager {
/**
* Prepares a command for sandboxed execution.
*
* @param options The options for the command.
* @returns A promise that resolves to a SandboxedCommand.
*/
prepareCommand(options: SandboxOptions): Promise<SandboxedCommand>;
/**
* Prepares a command for sandboxed execution synchronously.
*
* @param options The options for the command.
* @returns A SandboxedCommand.
*/
prepareCommandSync(options: SandboxOptions): SandboxedCommand;
}
/**
* Standard implementation of the SandboxManager.
*/
export class StandardSandboxManager implements SandboxManager {
constructor(private readonly config: Config) {}
async prepareCommand(options: SandboxOptions): Promise<SandboxedCommand> {
return this.prepareCommandSync(options);
}
prepareCommandSync(options: SandboxOptions): SandboxedCommand {
const sandboxConfig = this.config.getSandbox();
// If sandbox is not enabled or not configured, or if profile is UNSANDBOXED, return the original command.
if (!sandboxConfig?.enabled || options.profile === SandboxProfile.UNSANDBOXED) {
return {
program: options.command,
args: options.args,
};
}
// Default to WORKSPACE_WRITE if not specified.
const optionsWithProfile = {
...options,
profile: options.profile ?? SandboxProfile.WORKSPACE_WRITE,
};
// Handle macOS Seatbelt sandboxing.
if (process.platform === 'darwin') {
try {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-sandbox-'));
const profilePath = path.join(tempDir, 'sandbox.sb');
// Start with explicitly allowed paths from config
const allowedPaths = [...(sandboxConfig.allowedPaths || [])];
try {
const sandboxingTomlPath = path.join(path.resolve(options.cwd), '.gemini', 'sandboxing.toml');
if (fs.existsSync(sandboxingTomlPath)) {
const content = fs.readFileSync(sandboxingTomlPath, 'utf8');
const parsed = toml.parse(content) as Record<string, unknown>;
const sandboxSection = parsed?.['sandbox'] as Record<string, unknown> | undefined;
if (sandboxSection?.['allowedPaths'] && Array.isArray(sandboxSection['allowedPaths'])) {
allowedPaths.push(...(sandboxSection['allowedPaths'] as string[]));
}
}
} catch (error) {
debugLogger.error('Failed to parse .gemini/sandboxing.toml:', error);
}
fs.writeFileSync(
profilePath,
this.generateSeatbeltProfile(optionsWithProfile, allowedPaths),
);
return {
program: '/usr/bin/sandbox-exec',
args: ['-f', profilePath, options.command, ...options.args],
cleanup: () => {
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch (error) {
debugLogger.error('macOS Sandbox cleanup failed:', error);
}
},
};
} catch (error) {
debugLogger.error('macOS Sandbox setup failed:', error);
}
}
return {
program: options.command,
args: options.args,
};
}
/**
* Generates a macOS Seatbelt (.sb) profile content.
*/
private generateSeatbeltProfile(
options: SandboxOptions,
allowedPaths: string[],
): string {
const isReadOnly = options.profile === SandboxProfile.READ_ONLY;
const workspacePermission = isReadOnly ? 'file-read*' : 'file-read* file-write*';
const rules = [
'(version 1)',
'(deny default)',
'(allow process-fork)',
'(allow process-exec)',
'(allow signal (target same-sandbox))',
'(allow signal (target self))',
'(allow sysctl-read)',
'(allow pseudo-tty)',
'(allow mach-lookup)',
'(allow network-outbound)',
'(allow network-inbound (local ip "localhost:9229"))',
// Essential system paths (Read-only)
'(allow file-read*',
' (literal "/")',
' (subpath "/usr")',
' (subpath "/bin")',
' (subpath "/sbin")',
' (subpath "/Library")',
' (subpath "/System")',
' (subpath "/private")',
' (subpath "/dev")',
' (subpath "/etc")',
' (subpath "/opt")',
' (subpath "/Applications")',
')',
// Executable mapping
'(allow file-map-executable (subpath "/usr"))',
'(allow file-map-executable (subpath "/System"))',
'(allow file-map-executable (subpath "/bin"))',
'(allow file-map-executable (subpath "/sbin"))',
'(allow file-map-executable (subpath "/Library"))',
// Standard special files and IOCTL
'(allow file-read* file-write-data (literal "/dev/null"))',
'(allow file-read* file-write-data (literal "/dev/zero"))',
'(allow file-read* file-write* (literal "/dev/stdout"))',
'(allow file-read* file-write* (literal "/dev/stderr"))',
'(allow file-read* file-write* (literal "/dev/tty"))',
'(allow file-ioctl (regex #"^/dev/tty.*"))',
'(allow file-ioctl (literal "/dev/ptmx"))',
// Project Workspace and Temp
`(allow ${workspacePermission} (subpath "${path.resolve(options.cwd)}"))`,
`(allow file-map-executable (subpath "${path.resolve(options.cwd)}"))`,
...allowedPaths.map(p => `(allow ${workspacePermission} (subpath "${path.resolve(p)}"))`),
...allowedPaths.map(p => `(allow file-map-executable (subpath "${path.resolve(p)}"))`),
'(allow file-read* file-write* (subpath "/private/tmp"))',
'(allow file-read* file-write* (subpath "/tmp"))',
'(allow file-read* file-write* (subpath "/var/tmp"))',
'(allow file-read* file-write* (subpath "/private/var/folders"))',
// SECURITY: Protect sensitive configuration files from being modified by the AI.
// These rules override previous allows for the workspace.
`(deny file-write* (literal "${path.join(path.resolve(options.cwd), '.gitignore')}"))`,
`(deny file-write* (literal "${path.join(path.resolve(options.cwd), '.geminiignore')}"))`,
`(deny file-write* (literal "${path.join(path.resolve(options.cwd), '.env')}"))`,
// OPTIONAL: If you want to hide these files completely (prevent reading),
// uncomment the lines below. Note: this will break 'git status'.
// `(deny file-read* (literal "${path.join(path.resolve(options.cwd), '.gitignore')}"))`,
// `(deny file-read* (literal "${path.join(path.resolve(options.cwd), '.geminiignore')}"))`,
// Metadata access
'(allow file-read-metadata)',
// Ephemeral Rules
...(options.ephemeralRules || []),
];
return rules.join('\n');
}
}
@@ -0,0 +1,172 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
vi,
describe,
it,
expect,
beforeEach,
afterEach,
} from 'vitest';
import { ShellExecutionService } from './shellExecutionService.js';
import type { SandboxManager } from './sandboxManager.js';
import * as cp from 'node:child_process';
import EventEmitter from 'node:events';
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal();
return {
...(actual as object),
spawn: vi.fn(),
};
});
vi.mock('../utils/getPty.js', () => ({
getPty: vi.fn(),
}));
vi.mock('../utils/shell-utils.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../utils/shell-utils.js')>();
return {
...actual,
resolveExecutable: vi.fn(),
};
});
describe('ShellExecutionService Sandbox Wrapping', () => {
const mockSandboxManager: SandboxManager = {
prepareCommand: vi.fn(),
prepareCommandSync: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
ShellExecutionService.setSandboxManager(mockSandboxManager);
});
afterEach(() => {
// @ts-expect-error accessing private
ShellExecutionService.sandboxManager = undefined;
});
it('should wrap the command using sandboxManager and call cleanup', async () => {
const mockCleanup = vi.fn();
vi.mocked(mockSandboxManager.prepareCommand).mockResolvedValue({
program: 'sandbox-exec',
args: ['-f', 'profile.sb', '/bin/zsh', '-c', 'echo hi'],
cleanup: mockCleanup,
});
const mockChildEmitter = new EventEmitter();
// @ts-expect-error mock child
const mockChild: cp.ChildProcess = Object.assign(mockChildEmitter, {
pid: 123,
stdout: new EventEmitter(),
stderr: new EventEmitter(),
});
vi.mocked(cp.spawn).mockReturnValue(mockChild);
const abortController = new AbortController();
const handle = await ShellExecutionService.execute(
'echo hi',
'/tmp',
() => {},
abortController.signal,
false, // child_process
{
sanitizationConfig: {
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: [],
enableEnvironmentVariableRedaction: false,
},
},
);
// Verify prepareCommand was called
expect(mockSandboxManager.prepareCommand).toHaveBeenCalled();
// Verify spawn was called with wrapped arguments
expect(cp.spawn).toHaveBeenCalledWith(
'sandbox-exec',
['-f', 'profile.sb', '/bin/zsh', '-c', 'echo hi'],
expect.any(Object),
);
// Simulate exit
mockChild.emit('exit', 0, null);
await handle.result;
// Verify cleanup was called
expect(mockCleanup).toHaveBeenCalled();
});
it('should wrap the command using sandboxManager for PTY', async () => {
const mockPtyEmitter = new EventEmitter();
// @ts-expect-error mock pty process
const mockPtyProcess: import('@lydell/node-pty').IPty = Object.assign(
mockPtyEmitter,
{
pid: 456,
onData: vi.fn(),
onExit: vi.fn(),
},
);
const mockPtyModule = {
spawn: vi.fn().mockReturnValue(mockPtyProcess),
};
const { getPty } = await import('../utils/getPty.js');
vi.mocked(getPty).mockResolvedValue({
name: 'node-pty',
module: mockPtyModule,
});
const { resolveExecutable } = await import('../utils/shell-utils.js');
vi.mocked(resolveExecutable).mockResolvedValue('/bin/zsh');
const mockCleanup = vi.fn();
vi.mocked(mockSandboxManager.prepareCommand).mockResolvedValue({
program: 'sandbox-exec',
args: ['-f', 'profile.sb', '/bin/zsh', '-c', 'echo pty'],
cleanup: mockCleanup,
});
const abortController = new AbortController();
const handle = await ShellExecutionService.execute(
'echo pty',
'/tmp',
() => {},
abortController.signal,
true, // shouldUseNodePty
{
sanitizationConfig: {
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: [],
enableEnvironmentVariableRedaction: false,
},
},
);
expect(mockSandboxManager.prepareCommand).toHaveBeenCalled();
expect(mockPtyModule.spawn).toHaveBeenCalledWith(
'sandbox-exec',
['-f', 'profile.sb', '/bin/zsh', '-c', 'echo pty'],
expect.any(Object),
);
// Simulate exit
const exitCallback = (mockPtyProcess.onExit as any).mock.calls[0][0];
exitCallback({ exitCode: 0 });
await handle.result;
expect(mockCleanup).toHaveBeenCalled();
});
});
@@ -5,6 +5,7 @@
*/
import stripAnsi from 'strip-ansi';
import path from 'node:path';
import { getPty, type PtyImplementation } from '../utils/getPty.js';
import { spawn as cpSpawn, type ChildProcess } from 'node:child_process';
import { TextDecoder } from 'node:util';
@@ -12,8 +13,10 @@ import os from 'node:os';
import type { IPty } from '@lydell/node-pty';
import { getCachedEncodingForBuffer } from '../utils/systemEncoding.js';
import {
getSandboxManager,
getShellConfiguration,
resolveExecutable,
setSandboxManager as setCentralSandboxManager,
type ShellType,
} from '../utils/shell-utils.js';
import { isBinary } from '../utils/textUtils.js';
@@ -22,10 +25,8 @@ import {
serializeTerminalToObject,
type AnsiOutput,
} from '../utils/terminalSerializer.js';
import {
sanitizeEnvironment,
type EnvironmentSanitizationConfig,
} from './environmentSanitization.js';
import { sanitizeEnvironment, type EnvironmentSanitizationConfig } from './environmentSanitization.js';
import type { SandboxProfile, SandboxManager } from './sandboxManager.js';
import { killProcessGroup } from '../utils/process-utils.js';
const { Terminal } = pkg;
@@ -196,6 +197,10 @@ const getFullBufferText = (terminal: pkg.Terminal): string => {
*/
export class ShellExecutionService {
static setSandboxManager(manager: SandboxManager): void {
setCentralSandboxManager(manager);
}
private static activePtys = new Map<number, ActivePty>();
private static activeChildProcesses = new Map<number, ActiveChildProcess>();
private static exitedPtyInfo = new Map<
@@ -227,18 +232,50 @@ export class ShellExecutionService {
abortSignal: AbortSignal,
shouldUseNodePty: boolean,
shellExecutionConfig: ShellExecutionConfig,
profile?: SandboxProfile,
ephemeralRules?: string[],
): Promise<ShellExecutionHandle> {
const { executable, argsPrefix, shell } = getShellConfiguration();
const resolvedExecutable = (await resolveExecutable(executable)) ?? executable;
const guardedCommand = ensurePromptvarsDisabled(commandToExecute, shell);
const originalArgs = [...argsPrefix, guardedCommand];
let finalExecutable = resolvedExecutable;
let finalArgs = originalArgs;
let cleanup: (() => void) | undefined;
const manager = getSandboxManager();
if (manager) {
const sandboxed = await manager.prepareCommand({
command: resolvedExecutable,
args: originalArgs,
cwd,
env: sanitizeEnvironment(
process.env,
shellExecutionConfig.sanitizationConfig,
),
profile,
ephemeralRules,
});
finalExecutable = sandboxed.program;
finalArgs = sandboxed.args;
cleanup = sandboxed.cleanup;
console.error(`[DEBUG] Executing: ${finalExecutable} ${finalArgs.join(' ')}`);
}
if (shouldUseNodePty) {
const ptyInfo = await getPty();
if (ptyInfo) {
try {
return await this.executeWithPty(
commandToExecute,
finalExecutable,
finalArgs,
cwd,
onOutputEvent,
abortSignal,
shellExecutionConfig,
ptyInfo,
cleanup,
);
} catch (_e) {
// Fallback to child_process
@@ -247,11 +284,13 @@ export class ShellExecutionService {
}
return this.childProcessFallback(
commandToExecute,
finalExecutable,
finalArgs,
cwd,
onOutputEvent,
abortSignal,
shellExecutionConfig.sanitizationConfig,
cleanup,
);
}
@@ -293,17 +332,16 @@ export class ShellExecutionService {
}
private static childProcessFallback(
commandToExecute: string,
executable: string,
spawnArgs: string[],
cwd: string,
onOutputEvent: (event: ShellOutputEvent) => void,
abortSignal: AbortSignal,
sanitizationConfig: EnvironmentSanitizationConfig,
cleanup?: () => void,
): ShellExecutionHandle {
try {
const isWindows = os.platform() === 'win32';
const { executable, argsPrefix, shell } = getShellConfiguration();
const guardedCommand = ensurePromptvarsDisabled(commandToExecute, shell);
const spawnArgs = [...argsPrefix, guardedCommand];
const child = cpSpawn(executable, spawnArgs, {
cwd,
@@ -410,11 +448,11 @@ export class ShellExecutionService {
}
};
const handleExit = (
const handleExit = async (
code: number | null,
signal: NodeJS.Signals | null,
) => {
const { finalBuffer } = cleanup();
const { finalBuffer } = cleanupProcess();
let combinedOutput = state.output;
@@ -425,10 +463,15 @@ export class ShellExecutionService {
combinedOutput += truncationMessage;
}
const finalStrippedOutput = stripAnsi(combinedOutput).trim();
let finalStrippedOutput = stripAnsi(combinedOutput).trim();
const exitCode = code;
const exitSignal = signal ? os.constants.signals[signal] : null;
const violation = ShellExecutionService.detectSandboxViolation(exitCode, exitSignal, finalStrippedOutput, cwd);
if (violation) {
finalStrippedOutput += `\n[Sandbox Violation Detected]${typeof violation === 'string' ? `: ${violation}` : ''}`;
}
if (child.pid) {
const event: ShellOutputEvent = {
type: 'exit',
@@ -443,6 +486,10 @@ export class ShellExecutionService {
this.activeListeners.delete(child.pid);
}
if (cleanup) {
cleanup();
}
resolve({
rawOutput: finalBuffer,
output: finalStrippedOutput,
@@ -478,7 +525,7 @@ export class ShellExecutionService {
handleExit(code, signal);
});
function cleanup() {
function cleanupProcess() {
exited = true;
abortSignal.removeEventListener('abort', abortHandler);
if (stdoutDecoder) {
@@ -541,12 +588,14 @@ export class ShellExecutionService {
}
private static async executeWithPty(
commandToExecute: string,
executable: string,
args: string[],
cwd: string,
onOutputEvent: (event: ShellOutputEvent) => void,
abortSignal: AbortSignal,
shellExecutionConfig: ShellExecutionConfig,
ptyInfo: PtyImplementation,
cleanup?: () => void,
): Promise<ShellExecutionHandle> {
if (!ptyInfo) {
// This should not happen, but as a safeguard...
@@ -555,17 +604,6 @@ export class ShellExecutionService {
try {
const cols = shellExecutionConfig.terminalWidth ?? 80;
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, {
@@ -799,7 +837,7 @@ export class ShellExecutionService {
// Ignore errors during cleanup
}
const finalize = () => {
const finalize = async () => {
render(true);
// Store exit info for late subscribers (e.g. backgrounding race condition)
@@ -825,9 +863,20 @@ export class ShellExecutionService {
const finalBuffer = Buffer.concat(outputChunks);
if (cleanup) {
cleanup();
}
let finalOutput = getFullBufferText(headlessTerminal);
const violation = ShellExecutionService.detectSandboxViolation(exitCode, signal ?? null, stripAnsi(finalOutput).trim(), cwd);
if (violation) {
finalOutput += `\n[Sandbox Violation Detected]${typeof violation === 'string' ? `: ${violation}` : ''}`;
}
resolve({
rawOutput: finalBuffer,
output: getFullBufferText(headlessTerminal),
output: finalOutput,
exitCode,
signal: signal ?? null,
error,
@@ -1192,4 +1241,101 @@ export class ShellExecutionService {
}
}
}
public static detectSandboxViolation(exitCode: number | null, signal: string | number | null, output: string, cwd: string): string | boolean {
if (exitCode === 0 && !signal) {
return false;
}
let extractedBlockedPath: string | null = null;
// Attempt to extract the exact path first
const gitMatch = output.match(/fatal: not a git repository:\s*([^\s]+)/i);
if (gitMatch && gitMatch[1]) {
extractedBlockedPath = path.resolve(cwd, gitMatch[1]);
}
if (!extractedBlockedPath) {
const dyldMatch = output.match(/'([^']+)'\s+\(file system sandbox blocked/i);
if (dyldMatch && dyldMatch[1]) {
extractedBlockedPath = path.resolve(cwd, dyldMatch[1]);
}
}
if (!extractedBlockedPath) {
const nodeMatch = output.match(/EPERM:\s+(?:operation not permitted|permission denied),\s*(?:[a-z]+)\s+['"]?([^<>'"]+)['"]?/i);
if (nodeMatch && nodeMatch[1]) {
extractedBlockedPath = path.resolve(cwd, nodeMatch[1]);
}
}
if (!extractedBlockedPath) {
const match = output.match(/(.*?):\s+(?:Operation not permitted|Permission denied)/i);
if (match) {
const text = match[1];
if (text && !text.includes('Error: EPERM')) {
const quotedMatch = text.match(/['"]([^<>'"]+)['"]/);
if (quotedMatch && quotedMatch[1]) {
extractedBlockedPath = path.resolve(cwd, quotedMatch[1]);
} else {
const parts = text.split(':').map((p) => p.trim());
const extractedPath = parts[parts.length - 1];
if (extractedPath) {
extractedBlockedPath = path.resolve(cwd, extractedPath);
}
}
}
}
}
if (extractedBlockedPath) {
// Auto-expand .git paths to the root of the .git directory to prevent
// the user from having to approve config, packed-refs, HEAD, etc., one by one.
const gitIndex = extractedBlockedPath.indexOf('/.git/');
if (gitIndex !== -1) {
extractedBlockedPath = extractedBlockedPath.substring(0, gitIndex + 5);
}
// Auto-expand node_modules to the root of the node_modules directory
// to prevent individual file approvals for npm and node executions.
const nodeModulesIndex = extractedBlockedPath.indexOf('/node_modules/');
if (nodeModulesIndex !== -1) {
extractedBlockedPath = extractedBlockedPath.substring(0, nodeModulesIndex + 14);
}
return extractedBlockedPath;
}
const QUICK_REJECT_EXIT_CODES = [2, 126, 127];
if (exitCode !== null && QUICK_REJECT_EXIT_CODES.includes(exitCode)) {
return false;
}
const SANDBOX_DENIED_KEYWORDS = [
'operation not permitted',
'permission denied',
'read-only file system',
'seccomp',
'sandbox',
'landlock',
'failed to write file',
];
const lowerOutput = output.toLowerCase();
const hasSandboxKeyword = SANDBOX_DENIED_KEYWORDS.some((keyword) =>
lowerOutput.includes(keyword),
);
if (hasSandboxKeyword) {
return true;
}
// Signal 6 (SIGABRT) is common for macOS sandbox terminations
// Signal 31 (SIGSYS) is common for Linux seccomp terminations
if (signal === 6 || signal === 'SIGABRT' || signal === 31 || signal === 'SIGSYS') {
return true;
}
return false;
}
}
@@ -109,6 +109,14 @@ export function getShellDeclaration(
description:
'Set to true if this command should be run in the background (e.g. for long-running servers or watchers). The command will be started, allowed to run for a brief moment to check for immediate errors, and then moved to the background.',
},
required_sandbox_paths: {
type: 'array',
items: {
type: 'string',
},
description:
'(OPTIONAL) If you know this command will need to access specific files or directories outside the workspace (e.g. global binaries, homebrew paths, global config files), list their absolute paths here. If the sandbox blocks the command, the user will be asked to approve these broader paths instead of individual files, preventing multiple interruption prompts.',
},
},
required: [SHELL_PARAM_COMMAND],
},
+3
View File
@@ -11,6 +11,7 @@ import path from 'node:path';
import { spawn } from 'node:child_process';
import { globStream } from 'glob';
import { execStreaming } from '../utils/shell-utils.js';
import { SandboxProfile } from '../services/sandboxManager.js';
import {
DEFAULT_TOTAL_MAX_MATCHES,
DEFAULT_SEARCH_TIMEOUT_MS,
@@ -370,6 +371,7 @@ class GrepToolInvocation extends BaseToolInvocation<
cwd: absolutePath,
signal: options.signal,
allowedExitCodes: [0, 1],
profile: SandboxProfile.READ_ONLY,
});
const results: GrepMatch[] = [];
@@ -441,6 +443,7 @@ class GrepToolInvocation extends BaseToolInvocation<
cwd: absolutePath,
signal: options.signal,
allowedExitCodes: [0, 1],
profile: SandboxProfile.READ_ONLY,
});
for await (const line of generator) {
@@ -36,6 +36,7 @@ import {
populateMcpServerCommand,
type McpContext,
} from './mcp-client.js';
import { SandboxedTransport } from './sandboxed-transport.js';
import type { ToolRegistry } from './tool-registry.js';
import type { ResourceRegistry } from '../resources/resource-registry.js';
import * as fs from 'node:fs';
@@ -74,6 +75,20 @@ vi.mock('../mcp/oauth-token-storage.js');
vi.mock('../mcp/oauth-utils.js');
vi.mock('google-auth-library');
import { GoogleAuth } from 'google-auth-library';
vi.mock('../utils/shell-utils.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../utils/shell-utils.js')>();
return {
...actual,
prepareSandboxedCommand: vi.fn().mockImplementation((cmd: string, args: string[]) =>
Promise.resolve({
program: cmd,
args,
cleanup: vi.fn(),
}),
),
};
});
import { prepareSandboxedCommand } from '../utils/shell-utils.js';
vi.mock('../utils/events.js', () => ({
coreEvents: {
@@ -99,6 +114,13 @@ describe('mcp-client', () => {
path.join(os.tmpdir(), 'gemini-agent-test-'),
);
workspaceContext = new WorkspaceContext(testWorkspace);
vi.mocked(prepareSandboxedCommand).mockImplementation((cmd: string, args: string[]) =>
Promise.resolve({
program: cmd,
args,
cleanup: vi.fn(),
}),
);
});
afterEach(() => {
@@ -1945,6 +1967,50 @@ describe('mcp-client', () => {
expect(callArgs.env!['GEMINI_CLI']).toBe('1');
});
it('should apply sandboxing to stdio transport', async () => {
const mockedTransport = vi
.spyOn(SdkClientStdioLib, 'StdioClientTransport')
.mockReturnValue({} as SdkClientStdioLib.StdioClientTransport);
const mockCleanup = vi.fn();
vi.mocked(prepareSandboxedCommand).mockResolvedValue({
program: 'sandboxed-command',
args: ['--sandboxed-arg'],
cleanup: mockCleanup,
});
const transport = await createTransport(
'test-server',
{
command: 'original-command',
args: ['--original-arg'],
cwd: 'test/cwd',
},
false,
MOCK_CONTEXT,
);
expect(prepareSandboxedCommand).toHaveBeenCalledWith(
'original-command',
['--original-arg'],
expect.objectContaining({
cwd: 'test/cwd',
env: expect.anything(),
}),
);
expect(mockedTransport).toHaveBeenCalledWith(
expect.objectContaining({
command: 'sandboxed-command',
args: ['--sandboxed-arg'],
cwd: 'test/cwd',
stderr: 'pipe',
}),
);
expect(transport).toBeInstanceOf(SandboxedTransport);
});
it('should exclude extension settings with undefined values from environment', async () => {
const mockedTransport = vi
.spyOn(SdkClientStdioLib, 'StdioClientTransport')
+30 -9
View File
@@ -46,6 +46,7 @@ import { GoogleCredentialProvider } from '../mcp/google-auth-provider.js';
import { ServiceAccountImpersonationProvider } from '../mcp/sa-impersonation-provider.js';
import { DiscoveredMCPTool } from './mcp-tool.js';
import { XcodeMcpBridgeFixTransport } from './xcode-mcp-fix-transport.js';
import { SandboxedTransport } from './sandboxed-transport.js';
import type { CallableTool, FunctionCall, Part, Tool } from '@google/genai';
import { basename } from 'node:path';
@@ -84,6 +85,7 @@ import {
GEMINI_CLI_IDENTIFICATION_ENV_VAR,
GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE,
} from '../services/shellExecutionService.js';
import { prepareSandboxedCommand } from '../utils/shell-utils.js';
export const MCP_DEFAULT_TIMEOUT_MSEC = 10 * 60 * 1000; // default to 10 minutes
@@ -2218,14 +2220,31 @@ export async function createTransport(
}
}
const {
program: sandboxedCommand,
args: sandboxedArgs,
cleanup,
} = await prepareSandboxedCommand(
mcpServerConfig.command,
mcpServerConfig.args || [],
{
cwd: mcpServerConfig.cwd,
env: finalEnv,
},
);
let transport: Transport = new StdioClientTransport({
command: mcpServerConfig.command,
args: mcpServerConfig.args || [],
command: sandboxedCommand,
args: sandboxedArgs,
env: finalEnv,
cwd: mcpServerConfig.cwd,
stderr: 'pipe',
});
if (cleanup) {
transport = new SandboxedTransport(transport, cleanup);
}
// Fix for Xcode 26.3 mcpbridge non-compliant responses
// It returns JSON in `content` instead of `structuredContent`
if (
@@ -2236,15 +2255,17 @@ export async function createTransport(
}
if (debugMode) {
// The `XcodeMcpBridgeFixTransport` wrapper hides the underlying `StdioClientTransport`,
// Wrapper transports hide the underlying `StdioClientTransport`,
// which exposes `stderr` for debug logging. We need to unwrap it to attach the listener.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const underlyingTransport =
transport instanceof XcodeMcpBridgeFixTransport
? // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
(transport as any).transport
: transport;
let underlyingTransport: Transport = transport;
while (
underlyingTransport instanceof XcodeMcpBridgeFixTransport ||
underlyingTransport instanceof SandboxedTransport
) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-type-assertion
underlyingTransport = (underlyingTransport as any).transport;
}
if (
underlyingTransport instanceof StdioClientTransport &&
+46 -5
View File
@@ -5,6 +5,7 @@
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { MessageBusType } from '../confirmation-bus/types.js';
import { ReadFileTool, type ReadFileToolParams } from './read-file.js';
import { ToolErrorType } from './tool-error.js';
import path from 'node:path';
@@ -17,7 +18,10 @@ import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { StandardFileSystemService } from '../services/fileSystemService.js';
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
import { WorkspaceContext } from '../utils/workspaceContext.js';
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
import {
createMockMessageBus,
getMockMessageBusInstance,
} from '../test-utils/mock-message-bus.js';
import { GEMINI_IGNORE_FILE_NAME } from '../config/constants.js';
vi.mock('../telemetry/loggers.js', () => ({
@@ -97,11 +101,29 @@ describe('ReadFileTool', () => {
);
});
it('should throw error if path is outside root', () => {
it('should return error if path is outside root during execute', async () => {
const params: ReadFileToolParams = {
file_path: '/outside/root.txt',
};
expect(() => tool.build(params)).toThrow(/Path not in workspace/);
const invocation = tool.build(params);
// Simulate denying the prompt
setTimeout(() => {
const bus = (tool as any).messageBus;
const mockBus = getMockMessageBusInstance(bus) as any;
const msg = mockBus.publishedMessages.find((m: any) => m.type === MessageBusType.ASK_USER_REQUEST);
if (msg) {
mockBus.publish({
type: MessageBusType.ASK_USER_RESPONSE,
correlationId: msg.correlationId,
answers: {},
cancelled: true,
});
}
}, 50);
const result = await invocation.execute(new AbortController().signal);
expect(result.error?.type).toBe(ToolErrorType.PATH_NOT_IN_WORKSPACE);
});
it('should allow access to files in project temp directory', () => {
@@ -113,11 +135,30 @@ describe('ReadFileTool', () => {
expect(typeof result).not.toBe('string');
});
it('should show temp directory in error message when path is outside workspace and temp dir', () => {
it('should show temp directory in error message when path is outside workspace and temp dir during execute', async () => {
const params: ReadFileToolParams = {
file_path: '/completely/outside/path.txt',
};
expect(() => tool.build(params)).toThrow(/Path not in workspace/);
const invocation = tool.build(params);
// Simulate denying the prompt
setTimeout(() => {
const bus = (tool as any).messageBus;
const mockBus = getMockMessageBusInstance(bus) as any;
const msg = mockBus.publishedMessages.find((m: any) => m.type === MessageBusType.ASK_USER_REQUEST);
if (msg) {
mockBus.publish({
type: MessageBusType.ASK_USER_RESPONSE,
correlationId: msg.correlationId,
answers: {},
cancelled: true,
});
}
}, 50);
const result = await invocation.execute(new AbortController().signal);
expect(result.error?.message).toMatch(/Path not in workspace/);
expect(result.error?.message).toContain(tempRootDir);
});
it('should throw error if path is empty', () => {
+16 -16
View File
@@ -31,6 +31,7 @@ import { READ_FILE_TOOL_NAME, READ_FILE_DISPLAY_NAME } from './tool-names.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { READ_FILE_DEFINITION } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
import { promptSandboxExpansion } from '../utils/sandbox-prompt.js';
/**
* Parameters for the ReadFile tool
@@ -94,14 +95,21 @@ class ReadFileToolInvocation extends BaseToolInvocation<
'read',
);
if (validationError) {
return {
llmContent: validationError,
returnDisplay: 'Path not in workspace.',
error: {
message: validationError,
type: ToolErrorType.PATH_NOT_IN_WORKSPACE,
},
};
const decision = await promptSandboxExpansion(
this.messageBus,
this.resolvedPath,
this.config.getTargetDir()
);
if (decision !== 'Allow Once' && decision !== 'Always Allow') {
return {
llmContent: `Sandbox Violation: The command was blocked from accessing ${this.resolvedPath}. The user denied the sandbox expansion request.`,
returnDisplay: `Sandbox Violation: Blocked access to ${this.resolvedPath}. User denied request.`,
error: {
message: validationError,
type: ToolErrorType.PATH_NOT_IN_WORKSPACE,
},
};
}
}
const result = await processSingleFileContent(
@@ -208,14 +216,6 @@ export class ReadFileTool extends BaseDeclarativeTool<
params.file_path,
);
const validationError = this.config.validatePathAccess(
resolvedPath,
'read',
);
if (validationError) {
return validationError;
}
if (params.start_line !== undefined && params.start_line < 1) {
return 'start_line must be at least 1';
}
+2
View File
@@ -30,6 +30,7 @@ import {
} from '../utils/ignorePatterns.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { execStreaming } from '../utils/shell-utils.js';
import { SandboxProfile } from '../services/sandboxManager.js';
import {
DEFAULT_TOTAL_MAX_MATCHES,
DEFAULT_SEARCH_TIMEOUT_MS,
@@ -460,6 +461,7 @@ class GrepToolInvocation extends BaseToolInvocation<
const generator = execStreaming(rgPath, rgArgs, {
signal: options.signal,
allowedExitCodes: [0, 1],
profile: SandboxProfile.READ_ONLY,
});
let matchesFound = 0;
@@ -0,0 +1,73 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { SandboxedTransport } from './sandboxed-transport.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
describe('SandboxedTransport', () => {
it('should call cleanup when the transport is closed via close()', async () => {
const mockTransport: Transport = {
close: vi.fn().mockResolvedValue(undefined),
start: vi.fn().mockResolvedValue(undefined),
send: vi.fn().mockResolvedValue(undefined),
};
const cleanup = vi.fn();
const sandboxedTransport = new SandboxedTransport(mockTransport, cleanup);
await sandboxedTransport.close();
expect(mockTransport.close).toHaveBeenCalled();
expect(cleanup).toHaveBeenCalled();
});
it('should call cleanup when the underlying transport calls onclose', () => {
const mockTransport: Transport = {
close: vi.fn().mockResolvedValue(undefined),
start: vi.fn().mockResolvedValue(undefined),
send: vi.fn().mockResolvedValue(undefined),
};
const cleanup = vi.fn();
const sandboxedTransport = new SandboxedTransport(mockTransport, cleanup);
let oncloseCalled = false;
sandboxedTransport.onclose = () => {
oncloseCalled = true;
};
// Simulate underlying transport closing
mockTransport.onclose?.();
expect(cleanup).toHaveBeenCalled();
expect(oncloseCalled).toBe(true);
});
it('should forward messages and errors', () => {
const mockTransport: Transport = {
close: vi.fn().mockResolvedValue(undefined),
start: vi.fn().mockResolvedValue(undefined),
send: vi.fn().mockResolvedValue(undefined),
};
const cleanup = vi.fn();
const sandboxedTransport = new SandboxedTransport(mockTransport, cleanup);
const receivedMessages: any[] = [];
sandboxedTransport.onmessage = (msg) => receivedMessages.push(msg);
const receivedErrors: Error[] = [];
sandboxedTransport.onerror = (err) => receivedErrors.push(err);
const testMessage = { jsonrpc: '2.0', method: 'test' } as any;
mockTransport.onmessage?.(testMessage);
const testError = new Error('test error');
mockTransport.onerror?.(testError);
expect(receivedMessages).toEqual([testMessage]);
expect(receivedErrors).toEqual([testError]);
});
});
@@ -0,0 +1,45 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
/**
* A wrapper transport that handles sandbox environment cleanup.
*/
export class SandboxedTransport implements Transport {
constructor(
private readonly transport: Transport,
private readonly cleanup: () => void,
) {
this.transport.onmessage = (message) => this.onmessage?.(message);
this.transport.onerror = (error) => this.onerror?.(error);
this.transport.onclose = () => {
this.cleanup();
this.onclose?.();
};
}
onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage) => void;
async start(): Promise<void> {
await this.transport.start();
}
async close(): Promise<void> {
try {
await this.transport.close();
} finally {
this.cleanup();
}
}
async send(message: JSONRPCMessage): Promise<void> {
await this.transport.send(message);
}
}
+46 -25
View File
@@ -43,6 +43,7 @@ vi.mock('../utils/summarizer.js');
import { initializeShellParsers } from '../utils/shell-utils.js';
import { ShellTool, OUTPUT_UPDATE_INTERVAL_MS } from './shell.js';
import { SandboxProfile } from '../services/sandboxManager.js';
import { debugLogger } from '../index.js';
import { type Config } from '../config/config.js';
import {
@@ -56,7 +57,6 @@ import { isSubpath } from '../utils/paths.js';
import * as crypto from 'node:crypto';
import * as summarizer from '../utils/summarizer.js';
import { ToolErrorType } from './tool-error.js';
import { ToolConfirmationOutcome } from './tools.js';
import { SHELL_TOOL_NAME } from './tool-names.js';
import { WorkspaceContext } from '../utils/workspaceContext.js';
import {
@@ -275,6 +275,8 @@ describe('ShellTool', () => {
expect.any(AbortSignal),
false,
{ pager: 'cat', sanitizationConfig: {} },
SandboxProfile.READ_ONLY,
[],
);
expect(result.llmContent).toContain('Background PIDs: 54322');
// The file should be deleted by the tool
@@ -300,6 +302,8 @@ describe('ShellTool', () => {
expect.any(AbortSignal),
false,
{ pager: 'cat', sanitizationConfig: {} },
SandboxProfile.READ_ONLY,
[],
);
});
@@ -321,6 +325,8 @@ describe('ShellTool', () => {
expect.any(AbortSignal),
false,
{ pager: 'cat', sanitizationConfig: {} },
SandboxProfile.READ_ONLY,
[],
);
});
@@ -367,6 +373,7 @@ describe('ShellTool', () => {
expect.any(AbortSignal),
false,
{ pager: 'cat', sanitizationConfig: {} },
SandboxProfile.READ_ONLY,
);
},
20000,
@@ -392,6 +399,42 @@ describe('ShellTool', () => {
expect(result.llmContent).not.toContain('pgrep');
});
it('should format return payload on sandbox violation correctly', async () => {
const invocation = shellTool.build({ command: 'cat /secret/file' });
const promise = invocation.execute(mockAbortSignal);
resolveShellExecution({
exitCode: 1,
output: '[Sandbox Violation Detected]: EPERM /secret/file',
rawOutput: Buffer.from('[Sandbox Violation Detected]: EPERM /secret/file'),
signal: null,
aborted: false,
pid: 12345,
executionMethod: 'child_process',
});
// Advance timers and simulate UI response
let msg: any = null;
const mockBus = getMockMessageBusInstance((shellTool as any).messageBus) as any;
for (let i = 0; i < 10; i++) {
await vi.advanceTimersByTimeAsync(10);
msg = mockBus.publishedMessages.find((m: any) => m.type === MessageBusType.ASK_USER_REQUEST);
if (msg) break;
}
if (msg) {
mockBus.publish({
type: MessageBusType.ASK_USER_RESPONSE,
correlationId: msg.correlationId,
answers: {},
cancelled: true,
});
}
const result = await promise;
expect(result.llmContent).toContain("Sandbox Violation: The command was blocked from accessing /secret/file. The user denied the sandbox expansion request.");
expect(result.returnDisplay).toContain('Sandbox Violation: Blocked access to /secret/file. User denied request.');
});
it('should return a SHELL_EXECUTE_ERROR for a command failure', async () => {
const error = new Error('command failed');
const invocation = shellTool.build({ command: 'user-command' });
@@ -582,37 +625,15 @@ describe('ShellTool', () => {
});
describe('shouldConfirmExecute', () => {
it('should request confirmation for a new command and allowlist it on "Always"', async () => {
it('should bypass confirmation for all commands in YOLO mode', async () => {
const params = { command: 'npm install' };
const invocation = shellTool.build(params);
// Accessing protected messageBus for testing purposes
const bus = (shellTool as unknown as { messageBus: MessageBus })
.messageBus;
const mockBus = getMockMessageBusInstance(
bus,
) as unknown as TestableMockMessageBus;
// Initially needs confirmation
mockBus.defaultToolDecision = 'ask_user';
const confirmation = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
expect(confirmation).not.toBe(false);
expect(confirmation && confirmation.type).toBe('exec');
if (confirmation && confirmation.type === 'exec') {
await confirmation.onConfirm(ToolConfirmationOutcome.ProceedAlways);
}
// After "Always", it should be allowlisted in the mock engine
mockBus.defaultToolDecision = 'allow';
const secondInvocation = shellTool.build({ command: 'npm test' });
const secondConfirmation = await secondInvocation.shouldConfirmExecute(
new AbortController().signal,
);
expect(secondConfirmation).toBe(false);
expect(confirmation).toBe(false);
});
it('should throw an error if validation fails', () => {
+258 -75
View File
@@ -11,6 +11,7 @@ import crypto from 'node:crypto';
import type { Config } from '../config/config.js';
import { debugLogger } from '../index.js';
import { ToolErrorType } from './tool-error.js';
import { ApprovalMode } from '../policy/types.js';
import {
BaseDeclarativeTool,
BaseToolInvocation,
@@ -31,6 +32,8 @@ import {
type ShellExecutionConfig,
type ShellOutputEvent,
} from '../services/shellExecutionService.js';
import { SandboxProfile } from '../services/sandboxManager.js';
import { ExecPolicyEngine } from '../services/execPolicyEngine.js';
import { formatBytes } from '../utils/formatters.js';
import type { AnsiOutput } from '../utils/terminalSerializer.js';
import {
@@ -44,23 +47,30 @@ import { SHELL_TOOL_NAME } from './tool-names.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { getShellDefinition } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
import { promptSandboxExpansion, addToSandboxingToml } from '../utils/sandbox-prompt.js';
export const OUTPUT_UPDATE_INTERVAL_MS = 1000;
// Delay so user does not see the output of the process before the process is moved to the background.
const BACKGROUND_DELAY_MS = 200;
export interface ShellToolParams {
command: string;
description?: string;
dir_path?: string;
is_background?: boolean;
required_sandbox_paths?: string[];
}
export class ShellToolInvocation extends BaseToolInvocation<
ShellToolParams,
ToolResult
> {
override async shouldConfirmExecute(
_abortSignal: AbortSignal,
): Promise<import('./tools.js').ToolCallConfirmationDetails | false> {
// YOLO Mode: Bypass pre-execution policy confirmation and rely entirely on the OS sandbox.
return false;
}
constructor(
private readonly config: Config,
params: ShellToolParams,
@@ -98,9 +108,10 @@ export class ShellToolInvocation extends BaseToolInvocation<
outcome === ToolConfirmationOutcome.ProceedAlways
) {
const command = stripShellWrapper(this.params.command);
const rootCommands = [...new Set(getCommandRoots(command))];
if (rootCommands.length > 0) {
return { commandPrefix: rootCommands };
const rootCommands = getCommandRoots(command);
const uniqueRootCommands = [...new Set(rootCommands)];
if (uniqueRootCommands.length > 0) {
return { commandPrefix: uniqueRootCommands };
}
return { commandPrefix: this.params.command };
}
@@ -128,7 +139,9 @@ export class ShellToolInvocation extends BaseToolInvocation<
.join(', ');
}
const rootCommands = [...new Set(getCommandRoots(command))];
const rootCommands = getCommandRoots(command);
const uniqueRootCommands = [...new Set(rootCommands)];
const redirection = hasRedirection(command);
// Rely entirely on PolicyEngine for interactive confirmation.
// If we are here, it means PolicyEngine returned ASK_USER (or no message bus),
@@ -138,7 +151,8 @@ export class ShellToolInvocation extends BaseToolInvocation<
title: 'Confirm Shell Command',
command: this.params.command,
rootCommand: rootCommandDisplay,
rootCommands,
rootCommands: uniqueRootCommands,
hasRedirection: redirection,
onConfirm: async (_outcome: ToolConfirmationOutcome) => {
// Policy updates are now handled centrally by the scheduler
},
@@ -221,79 +235,248 @@ export class ShellToolInvocation extends BaseToolInvocation<
once: true,
});
// Start timeout
resetTimeout();
const ephemeralRules: string[] = [];
const previouslyPromptedTargets = new Set<string>();
const approvedPaths: string[] = [];
let isAlwaysAllow = false;
let result;
const { result: resultPromise, pid } =
await ShellExecutionService.execute(
commandToExecute,
cwd,
(event: ShellOutputEvent) => {
resetTimeout(); // Reset timeout on any event
if (!updateOutput) {
return;
}
const configDir = path.join(cwd, '.gemini');
const policyEngine = new ExecPolicyEngine(configDir);
let evaluatedProfile = policyEngine.getProfileForCommand(commandToExecute);
let shouldUpdate = false;
switch (event.type) {
case 'data':
if (isBinaryStream) break;
cumulativeOutput = event.chunk;
shouldUpdate = true;
break;
case 'binary_detected':
isBinaryStream = true;
cumulativeOutput =
'[Binary output detected. Halting stream...]';
shouldUpdate = true;
break;
case 'binary_progress':
isBinaryStream = true;
cumulativeOutput = `[Receiving binary output... ${formatBytes(
event.bytesReceived,
)} received]`;
if (Date.now() - lastUpdateTime > OUTPUT_UPDATE_INTERVAL_MS) {
shouldUpdate = true;
}
break;
case 'exit':
break;
default: {
throw new Error('An unhandled ShellOutputEvent was found.');
}
}
if (shouldUpdate && !this.params.is_background) {
updateOutput(cumulativeOutput);
lastUpdateTime = Date.now();
}
},
combinedController.signal,
this.config.getEnableInteractiveShell(),
{
...shellExecutionConfig,
pager: 'cat',
sanitizationConfig:
shellExecutionConfig?.sanitizationConfig ??
this.config.sanitizationConfig,
},
);
if (pid) {
if (setPidCallback) {
setPidCallback(pid);
}
// If the model requested to run in the background, do so after a short delay.
if (this.params.is_background) {
setTimeout(() => {
ShellExecutionService.background(pid);
}, BACKGROUND_DELAY_MS);
}
const mode = this.config.getApprovalMode();
if (mode === ApprovalMode.AUTO_EDIT || mode === ApprovalMode.YOLO) {
evaluatedProfile = SandboxProfile.WORKSPACE_WRITE;
}
const result = await resultPromise;
while (true) {
// Start timeout
resetTimeout();
const { result: resultPromise, pid } =
await ShellExecutionService.execute(
commandToExecute,
cwd,
(event: ShellOutputEvent) => {
resetTimeout(); // Reset timeout on any event
if (!updateOutput) {
return;
}
let shouldUpdate = false;
switch (event.type) {
case 'data':
if (isBinaryStream) break;
cumulativeOutput = event.chunk;
shouldUpdate = true;
break;
case 'binary_detected':
isBinaryStream = true;
cumulativeOutput =
'[Binary output detected. Halting stream...]';
shouldUpdate = true;
break;
case 'binary_progress':
isBinaryStream = true;
cumulativeOutput = `[Receiving binary output... ${formatBytes(
event.bytesReceived,
)} received]`;
if (Date.now() - lastUpdateTime > OUTPUT_UPDATE_INTERVAL_MS) {
shouldUpdate = true;
}
break;
case 'exit':
break;
default: {
throw new Error('An unhandled ShellOutputEvent was found.');
}
}
if (shouldUpdate && !this.params.is_background) {
updateOutput(cumulativeOutput);
lastUpdateTime = Date.now();
}
},
combinedController.signal,
this.config.getEnableInteractiveShell(),
{
...shellExecutionConfig,
pager: 'cat',
sanitizationConfig:
shellExecutionConfig?.sanitizationConfig ??
this.config.sanitizationConfig,
},
evaluatedProfile,
ephemeralRules,
);
if (pid) {
if (setPidCallback) {
setPidCallback(pid);
}
// If the model requested to run in the background, do so after a short delay.
if (this.params.is_background) {
setTimeout(() => {
ShellExecutionService.background(pid);
}, BACKGROUND_DELAY_MS);
}
}
result = await resultPromise;
if (result.aborted) {
break;
}
if (result.output.includes('[Sandbox Violation Detected]')) {
let blockedPath = null;
const match = result.output.match(/\[Sandbox Violation Detected\]:?\s*(.*)?/);
if (match && match[1]) {
blockedPath = match[1].trim();
}
if (blockedPath && this.params.required_sandbox_paths) {
for (const requiredPath of this.params.required_sandbox_paths) {
if (blockedPath.startsWith(requiredPath)) {
blockedPath = requiredPath;
break;
}
}
}
const fallbackPrefix = commandToExecute.trim().split(/\s+/)[0] || 'command';
let promptTarget = blockedPath || fallbackPrefix;
// Loop protection MUST run before auto-approval.
let requiresUnsandboxed = false;
if (previouslyPromptedTargets.has(promptTarget)) {
requiresUnsandboxed = true;
} else {
previouslyPromptedTargets.add(promptTarget);
}
// Check for auto-approval based on similarity with previously approved paths in this run
let autoApproved = false;
if (!requiresUnsandboxed && blockedPath) {
for (const approved of approvedPaths) {
if (blockedPath === approved) {
continue; // Prevent infinite loop on the exact same path
}
const p1 = approved.split('/').filter(Boolean);
const p2 = blockedPath.split('/').filter(Boolean);
let i = 0;
while (i < p1.length && i < p2.length && p1[i] === p2[i]) {
i++;
}
// If they share at least 3 directories (e.g., /Users/name/folder)
if (i >= 3) {
const commonAncestor = '/' + p1.slice(0, i).join('/');
ephemeralRules.push(`(allow file-read* file-write* (subpath "${commonAncestor}"))`);
ephemeralRules.push(`(allow file-map-executable (subpath "${commonAncestor}"))`);
approvedPaths.push(commonAncestor);
try {
const realPath = await fsPromises.realpath(commonAncestor);
if (realPath !== commonAncestor) {
ephemeralRules.push(`(allow file-read* file-write* (subpath "${realPath}"))`);
ephemeralRules.push(`(allow file-map-executable (subpath "${realPath}"))`);
approvedPaths.push(realPath);
}
} catch (err) {
// Ignore if path doesn't exist
}
if (isAlwaysAllow) {
await addToSandboxingToml(cwd, commonAncestor);
}
autoApproved = true;
break;
}
}
}
if (autoApproved) {
evaluatedProfile = SandboxProfile.WORKSPACE_WRITE;
continue;
}
// If we've already prompted for this target, the sandbox is fundamentally incompatible. We must ask for completely unsandboxed execution.
if (requiresUnsandboxed && evaluatedProfile === SandboxProfile.UNSANDBOXED) {
return {
llmContent: `Sandbox Violation: The command '${promptTarget}' was executed entirely without the sandbox but still failed. The failure is not sandbox-related. Original output:\n${result.output}`,
returnDisplay: `Sandbox Violation: ${promptTarget} failed even when completely un-sandboxed.`
};
}
const decision = await promptSandboxExpansion(this.messageBus, promptTarget, cwd, !!blockedPath, requiresUnsandboxed);
if (decision === 'Allow Once' || decision === 'Always Allow') {
if (decision === 'Always Allow') {
isAlwaysAllow = true;
}
if (requiresUnsandboxed) {
evaluatedProfile = SandboxProfile.UNSANDBOXED;
if (isAlwaysAllow) {
const tokens = commandToExecute.trim().split(/\s+/);
let prefix: string[] = [];
if (tokens.length >= 2 && !tokens[0].includes('=')) {
prefix = tokens.slice(0, 2);
} else if (tokens.length > 0) {
prefix = [tokens[0]];
}
if (prefix.length > 0) {
await policyEngine.addRule(prefix, SandboxProfile.UNSANDBOXED);
}
}
continue;
}
if (blockedPath) {
ephemeralRules.push(`(allow file-read* file-write* (subpath "${blockedPath}"))`);
ephemeralRules.push(`(allow file-map-executable (subpath "${blockedPath}"))`);
approvedPaths.push(blockedPath);
try {
const realPath = await fsPromises.realpath(blockedPath);
if (realPath !== blockedPath) {
ephemeralRules.push(`(allow file-read* file-write* (subpath "${realPath}"))`);
ephemeralRules.push(`(allow file-map-executable (subpath "${realPath}"))`);
approvedPaths.push(realPath);
}
} catch (err) {
// Ignore if path doesn't exist or can't be resolved
}
}
if (isAlwaysAllow) {
// Try to add rule to execpolicy to authorize the command itself for the future
const tokens = commandToExecute.trim().split(/\s+/);
let prefix: string[] = [];
if (tokens.length >= 2 && !tokens[0].includes('=')) {
prefix = tokens.slice(0, 2);
} else if (tokens.length > 0) {
prefix = [tokens[0]];
}
if (prefix.length > 0) {
await policyEngine.addRule(prefix, SandboxProfile.WORKSPACE_WRITE);
}
}
evaluatedProfile = SandboxProfile.WORKSPACE_WRITE; // Promote to workspace write for retry
continue;
} else {
return {
llmContent: `Sandbox Violation: The sandbox prevented '${promptTarget}' from accessing files outside the workspace. The user denied the sandbox expansion request.\n\nOriginal output:\n${result.output}`,
returnDisplay: `Sandbox Violation: User denied sandbox expansion for ${promptTarget}.`
};
}
}
break;
}
const backgroundPIDs: number[] = [];
if (os.platform() !== 'win32') {
+6 -2
View File
@@ -132,7 +132,10 @@ export abstract class BaseToolInvocation<
*/
protected getPolicyUpdateOptions(
_outcome: ToolConfirmationOutcome,
): PolicyUpdateOptions | undefined {
):
| PolicyUpdateOptions
| undefined
| Promise<PolicyUpdateOptions | undefined> {
return undefined;
}
@@ -148,7 +151,7 @@ export abstract class BaseToolInvocation<
outcome === ToolConfirmationOutcome.ProceedAlwaysAndSave
) {
if (this._toolName) {
const options = this.getPolicyUpdateOptions(outcome);
const options = await this.getPolicyUpdateOptions(outcome);
void this.messageBus.publish({
type: MessageBusType.UPDATE_POLICY,
toolName: this._toolName,
@@ -772,6 +775,7 @@ export interface ToolExecuteConfirmationDetails {
rootCommand: string;
rootCommands: string[];
commands?: string[];
hasRedirection?: boolean;
}
export interface ToolMcpConfirmationDetails {
+58 -6
View File
@@ -19,6 +19,7 @@ import {
type WriteFileToolParams,
} from './write-file.js';
import { ToolErrorType } from './tool-error.js';
import { MessageBusType } from '../confirmation-bus/types.js';
import {
ToolConfirmationOutcome,
type FileDiff,
@@ -246,13 +247,30 @@ describe('WriteFileTool', () => {
expect(invocation.params).toEqual(params);
});
it('should throw an error for a path outside root', () => {
it('should return error for a path outside root during execute', async () => {
const outsidePath = path.resolve(tempDir, 'outside-root.txt');
const params = {
file_path: outsidePath,
content: 'hello',
};
expect(() => tool.build(params)).toThrow(/Path not in workspace/);
const invocation = tool.build(params);
setTimeout(() => {
const bus = (tool as any).messageBus;
const mockBus = getMockMessageBusInstance(bus) as any;
const msg = mockBus.publishedMessages.find((m: any) => m.type === MessageBusType.ASK_USER_REQUEST);
if (msg) {
mockBus.publish({
type: MessageBusType.ASK_USER_RESPONSE,
correlationId: msg.correlationId,
answers: {},
cancelled: true,
});
}
}, 50);
const result = await invocation.execute(new AbortController().signal);
expect(result.error?.type).toBe(ToolErrorType.PATH_NOT_IN_WORKSPACE);
});
it('should throw an error if path is a directory', () => {
@@ -893,12 +911,29 @@ describe('WriteFileTool', () => {
expect(() => tool.build(params)).not.toThrow();
});
it('should reject paths outside workspace root', () => {
it('should reject paths outside workspace root during execute', async () => {
const params = {
file_path: '/etc/passwd',
content: 'malicious',
};
expect(() => tool.build(params)).toThrow(/Path not in workspace/);
const invocation = tool.build(params);
setTimeout(() => {
const bus = (tool as any).messageBus;
const mockBus = getMockMessageBusInstance(bus) as any;
const msg = mockBus.publishedMessages.find((m: any) => m.type === MessageBusType.ASK_USER_REQUEST);
if (msg) {
mockBus.publish({
type: MessageBusType.ASK_USER_RESPONSE,
correlationId: msg.correlationId,
answers: {},
cancelled: true,
});
}
}, 50);
const result = await invocation.execute(new AbortController().signal);
expect(result.error?.type).toBe(ToolErrorType.PATH_NOT_IN_WORKSPACE);
});
it('should allow paths within the plans directory', () => {
@@ -909,12 +944,29 @@ describe('WriteFileTool', () => {
expect(() => tool.build(params)).not.toThrow();
});
it('should reject paths that try to escape the plans directory', () => {
it('should reject paths that try to escape the plans directory during execute', async () => {
const params = {
file_path: path.join(plansDir, '..', 'escaped.txt'),
content: 'malicious',
};
expect(() => tool.build(params)).toThrow(/Path not in workspace/);
const invocation = tool.build(params);
setTimeout(() => {
const bus = (tool as any).messageBus;
const mockBus = getMockMessageBusInstance(bus) as any;
const msg = mockBus.publishedMessages.find((m: any) => m.type === MessageBusType.ASK_USER_REQUEST);
if (msg) {
mockBus.publish({
type: MessageBusType.ASK_USER_RESPONSE,
correlationId: msg.correlationId,
answers: {},
cancelled: true,
});
}
}, 50);
const result = await invocation.execute(new AbortController().signal);
expect(result.error?.type).toBe(ToolErrorType.PATH_NOT_IN_WORKSPACE);
});
});
+16 -13
View File
@@ -46,6 +46,7 @@ import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { debugLogger } from '../utils/debugLogger.js';
import { WRITE_FILE_DEFINITION } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
import { promptSandboxExpansion } from '../utils/sandbox-prompt.js';
import { detectOmissionPlaceholders } from './omissionPlaceholderDetector.js';
import { isGemini3Model } from '../config/models.js';
@@ -240,14 +241,21 @@ class WriteFileToolInvocation extends BaseToolInvocation<
async execute(abortSignal: AbortSignal): Promise<ToolResult> {
const validationError = this.config.validatePathAccess(this.resolvedPath);
if (validationError) {
return {
llmContent: validationError,
returnDisplay: 'Error: Path not in workspace.',
error: {
message: validationError,
type: ToolErrorType.PATH_NOT_IN_WORKSPACE,
},
};
const decision = await promptSandboxExpansion(
this.messageBus,
this.resolvedPath,
this.config.getTargetDir()
);
if (decision !== 'Allow Once' && decision !== 'Always Allow') {
return {
llmContent: `Sandbox Violation: The command was blocked from accessing ${this.resolvedPath}. The user denied the sandbox expansion request.`,
returnDisplay: `Error: Blocked access to ${this.resolvedPath}. User denied request.`,
error: {
message: validationError,
type: ToolErrorType.PATH_NOT_IN_WORKSPACE,
},
};
}
}
const { content, ai_proposed_content, modified_by_user } = this.params;
@@ -464,11 +472,6 @@ export class WriteFileTool
const resolvedPath = path.resolve(this.config.getTargetDir(), filePath);
const validationError = this.config.validatePathAccess(resolvedPath);
if (validationError) {
return validationError;
}
try {
if (fs.existsSync(resolvedPath)) {
const stats = fs.lstatSync(resolvedPath);
+13 -17
View File
@@ -4,11 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { exec, execSync, spawn, spawnSync } from 'node:child_process';
import { exec, execSync } from 'node:child_process';
import { promisify } from 'node:util';
import { once } from 'node:events';
import { debugLogger } from './debugLogger.js';
import { coreEvents, CoreEvent, type EditorSelectedPayload } from './events.js';
import { spawnAsync, spawnSyncAsync } from './shell-utils.js';
const GUI_EDITORS = [
'vscode',
@@ -302,7 +303,7 @@ export async function openDiff(
if (isTerminalEditor(editor)) {
try {
const result = spawnSync(diffCommand.command, diffCommand.args, {
const result = await spawnSyncAsync(diffCommand.command, diffCommand.args, {
stdio: 'inherit',
});
if (result.error) {
@@ -317,22 +318,17 @@ export async function openDiff(
return;
}
return new Promise<void>((resolve, reject) => {
const childProcess = spawn(diffCommand.command, diffCommand.args, {
try {
await spawnAsync(diffCommand.command, diffCommand.args, {
stdio: 'inherit',
shell: process.platform === 'win32',
});
childProcess.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`${editor} exited with code ${code}`));
}
});
childProcess.on('error', (error) => {
reject(error);
});
});
} catch (error) {
if (error instanceof Error && error.message.includes('Command failed with exit code')) {
const match = error.message.match(/exit code (\d+)/);
const code = match ? match[1] : 'unknown';
throw new Error(`${editor} exited with code ${code}`);
}
throw error;
}
}
+74
View File
@@ -0,0 +1,74 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import crypto from 'node:crypto';
import path from 'node:path';
import fsPromises from 'node:fs/promises';
import toml from '@iarna/toml';
import { MessageBusType } from '../confirmation-bus/types.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { debugLogger } from '../index.js';
import { ToolConfirmationOutcome } from '../tools/tools.js';
export async function addToSandboxingToml(cwd: string, allowedPath: string) {
try {
const configDir = path.join(path.resolve(cwd), '.gemini');
const tomlPath = path.join(configDir, 'sandboxing.toml');
let parsed: Record<string, unknown> = {};
try {
const content = await fsPromises.readFile(tomlPath, 'utf8');
parsed = toml.parse(content) as Record<string, unknown>;
} catch {
await fsPromises.mkdir(configDir, { recursive: true });
}
const sandboxSection = (parsed['sandbox'] as Record<string, unknown>) || {};
const allowedPathsList = (sandboxSection['allowedPaths'] as string[]) || [];
if (!allowedPathsList.includes(allowedPath)) {
allowedPathsList.push(allowedPath);
sandboxSection['allowedPaths'] = allowedPathsList;
parsed['sandbox'] = sandboxSection;
await fsPromises.writeFile(tomlPath, toml.stringify(parsed as any));
}
} catch (e) {
debugLogger.error('Failed to update sandboxing.toml:', e);
}
}
export async function promptSandboxExpansion(
messageBus: MessageBus,
blockedPath: string,
cwd: string,
saveToSandboxingToml: boolean = true,
requiresUnsandboxed: boolean = false
): Promise<'Allow Once' | 'Always Allow' | 'Deny'> {
const decision = await new Promise<'Allow Once' | 'Always Allow' | 'Deny'>((resolve) => {
const correlationId = crypto.randomUUID();
const handler = (msg: unknown) => {
if (msg && typeof msg === 'object' && 'type' in msg && msg.type === MessageBusType.TOOL_CONFIRMATION_RESPONSE && 'correlationId' in msg && msg.correlationId === correlationId) {
messageBus.unsubscribe(MessageBusType.TOOL_CONFIRMATION_RESPONSE, handler);
const m = msg as any;
if (m.outcome === ToolConfirmationOutcome.ProceedOnce) {
resolve('Allow Once');
} else if (m.outcome === ToolConfirmationOutcome.ProceedAlwaysAndSave) {
resolve('Always Allow');
} else {
resolve('Deny');
}
}
};
messageBus.subscribe(MessageBusType.TOOL_CONFIRMATION_RESPONSE, handler);
void messageBus.publish({
type: MessageBusType.SANDBOX_EXPANSION_REQUEST,
correlationId,
blockedPath,
});
});
if (decision === 'Always Allow' && saveToSandboxingToml) {
await addToSandboxingToml(cwd, blockedPath);
}
return decision;
}
+189 -50
View File
@@ -11,15 +11,88 @@ import { quote } from 'shell-quote';
import {
spawn,
spawnSync,
type SpawnOptions,
type SpawnOptionsWithoutStdio,
type SpawnSyncOptions,
} from 'node:child_process';
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 {
SandboxProfile,
SandboxManager,
SandboxedCommand
} from '../services/sandboxManager.js';
export const SHELL_TOOL_NAMES = ['run_shell_command', 'ShellTool'];
let sandboxManager: SandboxManager | undefined;
/**
* Sets the global SandboxManager to be used for all shell spawning operations.
*/
export function setSandboxManager(manager: SandboxManager): void {
sandboxManager = manager;
}
/**
* Gets the global SandboxManager.
*/
export function getSandboxManager(): SandboxManager | undefined {
return sandboxManager;
}
function prepareSandboxedCommandSync(
command: string,
args: string[],
options?: {
cwd?: string | URL;
env?: NodeJS.ProcessEnv;
profile?: SandboxProfile;
},
): SandboxedCommand {
if (!sandboxManager) return { program: command, args };
const cwd =
options?.cwd instanceof URL
? options.cwd.pathname
: options?.cwd ?? process.cwd();
return sandboxManager.prepareCommandSync({
command,
args,
cwd,
env: options?.env ?? process.env,
profile: options?.profile,
});
}
export async function prepareSandboxedCommand(
command: string,
args: string[],
options?: {
cwd?: string | URL;
env?: NodeJS.ProcessEnv;
profile?: SandboxProfile;
},
): Promise<SandboxedCommand> {
if (!sandboxManager) return { program: command, args };
const cwd =
options?.cwd instanceof URL
? options.cwd.pathname
: options?.cwd ?? process.cwd();
return sandboxManager.prepareCommand({
command,
args,
cwd,
env: options?.env ?? process.env,
profile: options?.profile,
});
}
/**
* An identifier for the shell type.
*/
@@ -445,7 +518,11 @@ function parsePowerShellCommandDetails(
}
try {
const result = spawnSync(
const {
program: finalCommand,
args: finalArgs,
cleanup,
} = prepareSandboxedCommandSync(
executable,
[
'-NoLogo',
@@ -459,10 +536,19 @@ function parsePowerShellCommandDetails(
...process.env,
[POWERSHELL_COMMAND_ENV]: command,
},
encoding: 'utf-8',
},
);
const result = spawnSync(finalCommand, finalArgs, {
env: {
...process.env,
[POWERSHELL_COMMAND_ENV]: command,
},
encoding: 'utf-8',
});
if (cleanup) cleanup();
if (result.error || result.status !== 0) {
return null;
}
@@ -737,25 +823,39 @@ 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?: SpawnOptions & { profile?: SandboxProfile },
): Promise<{ stdout: string; stderr: string }> => {
const {
program: finalCommand,
args: finalArgs,
cleanup,
} = await prepareSandboxedCommand(command, args, options);
return new Promise((resolve, reject) => {
const child = options
? spawn(finalCommand, finalArgs, options)
: spawn(finalCommand, finalArgs);
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => {
stdout += data.toString();
});
if (child.stdout) {
child.stdout.on('data', (data: Buffer | string) => {
stdout += data.toString();
});
}
child.stderr.on('data', (data) => {
stderr += data.toString();
});
if (child.stderr) {
child.stderr.on('data', (data: Buffer | string) => {
stderr += data.toString();
});
}
child.on('close', (code) => {
child.on('close', (code: number | null) => {
if (cleanup) cleanup();
if (code === 0) {
resolve({ stdout, stderr });
} else {
@@ -763,10 +863,38 @@ export const spawnAsync = (
}
});
child.on('error', (err) => {
child.on('error', (err: Error) => {
if (cleanup) cleanup();
reject(err);
});
});
};
/**
* Executes a command synchronously but prepares the sandbox asynchronously.
* This is useful for terminal-based tools that need to inherit stdio.
*/
export const spawnSyncAsync = async (
command: string,
args: string[],
options?: SpawnSyncOptions & { profile?: SandboxProfile },
): Promise<{ status: number | null; error?: Error }> => {
const {
program: finalCommand,
args: finalArgs,
cleanup,
} = await prepareSandboxedCommand(command, args, options);
try {
const result = spawnSync(finalCommand, finalArgs, options);
return {
status: result.status,
error: result.error,
};
} finally {
if (cleanup) cleanup();
}
};
/**
* Executes a command and yields lines of output as they appear.
@@ -782,9 +910,16 @@ export async function* execStreaming(
options?: SpawnOptionsWithoutStdio & {
signal?: AbortSignal;
allowedExitCodes?: number[];
profile?: SandboxProfile;
},
): AsyncGenerator<string, void, void> {
const child = spawn(command, args, {
const {
program: finalCommand,
args: finalArgs,
cleanup,
} = await prepareSandboxedCommand(command, args, options);
const child = spawn(finalCommand, finalArgs, {
...options,
// ensure we don't open a window on windows if possible/relevant
windowsHide: true,
@@ -845,45 +980,49 @@ export async function* execStreaming(
}
// Ensure we wait for the process to exit to check codes
await new Promise<void>((resolve, reject) => {
// If an error occurred before we got here (e.g. spawn failure), reject immediately.
if (error) {
reject(error);
return;
}
function checkExit(code: number | null) {
// If we aborted or killed it manually, we treat it as success (stop waiting)
if (options?.signal?.aborted || killedByGenerator) {
resolve();
try {
await new Promise<void>((resolve, reject) => {
// If an error occurred before we got here (e.g. spawn failure), reject immediately.
if (error) {
reject(error);
return;
}
const allowed = options?.allowedExitCodes ?? [0];
if (code !== null && allowed.includes(code)) {
resolve();
} else {
// If we have an accumulated error or explicit error event
if (error) reject(error);
else {
const stderr = Buffer.concat(errorChunks).toString('utf8');
const truncatedMsg =
stderrTotalBytes >= MAX_STDERR_BYTES ? '...[truncated]' : '';
reject(
new Error(
`Process exited with code ${code}: ${stderr}${truncatedMsg}`,
),
);
function checkExit(code: number | null) {
// If we aborted or killed it manually, we treat it as success (stop waiting)
if (options?.signal?.aborted || killedByGenerator) {
resolve();
return;
}
const allowed = options?.allowedExitCodes ?? [0];
if (code !== null && allowed.includes(code)) {
resolve();
} else {
// If we have an accumulated error or explicit error event
if (error) reject(error);
else {
const stderr = Buffer.concat(errorChunks).toString('utf8');
const truncatedMsg =
stderrTotalBytes >= MAX_STDERR_BYTES ? '...[truncated]' : '';
reject(
new Error(
`Process exited with code ${code}: ${stderr}${truncatedMsg}`,
),
);
}
}
}
}
if (child.exitCode !== null) {
checkExit(child.exitCode);
} else {
child.on('close', (code) => checkExit(code));
child.on('error', (err) => reject(err));
}
});
if (child.exitCode !== null) {
checkExit(child.exitCode);
} else {
child.on('close', (code) => checkExit(code));
child.on('error', (err) => reject(err));
}
});
} finally {
if (cleanup) cleanup();
}
}
}
+16
View File
@@ -0,0 +1,16 @@
const { spawnSync } = require('child_process');
const { execSync } = require('child_process');
const result = spawnSync('sandbox-exec', ['-p', '(version 1)(allow default)(deny file-write* (literal "/tmp/test-sandbox-file.txt"))', 'bash', '-c', 'echo "hello" > /tmp/test-sandbox-file.txt']);
console.log('Exit Code:', result.status);
console.log('Signal:', result.signal);
console.log('Stderr:', result.stderr.toString());
const pid = result.pid;
try {
const log = execSync(`log show --predicate 'process == "sandboxd" and eventMessage contains "${pid}"' --last 1m --style compact`);
console.log('Log:', log.toString());
} catch (e) {
console.log('Log Error:', e.toString());
}
+16
View File
@@ -0,0 +1,16 @@
const { spawnSync } = require('child_process');
const { execSync } = require('child_process');
const result = spawnSync('sandbox-exec', ['-p', '(version 1)(allow default)(deny file-write* (literal "/tmp/test-sandbox-file.txt"))', 'bash', '-c', 'echo "hello" > /tmp/test-sandbox-file.txt']);
console.log('Exit Code:', result.status);
console.log('Signal:', result.signal);
console.log('Stderr:', result.stderr.toString());
const pid = result.pid;
try {
const log = execSync(`log show --predicate 'process == "sandboxd" and eventMessage contains "${pid}"' --last 1m --style compact`);
console.log('Log:', log.toString());
} catch (e) {
console.log('Log Error:', e.toString());
}