feat(sandbox): dynamic macOS sandbox expansion and worktree support (#23301)

This commit is contained in:
Gal Zahavi
2026-03-23 21:48:13 -07:00
committed by GitHub
parent 37c8de3c06
commit 36e6445dba
40 changed files with 2201 additions and 183 deletions

View File

@@ -1625,6 +1625,7 @@ function toPermissionOptions(
case 'info':
case 'ask_user':
case 'exit_plan_mode':
case 'sandbox_expansion':
break;
default: {
const unreachable: never = confirmation;

View File

@@ -47,6 +47,7 @@ describe('ToolConfirmationQueue', () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
getApprovalMode: () => 'default',
getDisableAlwaysAllow: () => false,
getModel: () => 'gemini-pro',
getDebugMode: () => false,

View File

@@ -22,6 +22,7 @@ describe('ToolConfirmationMessage Redirection', () => {
isTrustedFolder: () => true,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
getApprovalMode: () => 'default',
} as unknown as Config;
it('should display redirection warning and tip for redirected commands', async () => {

View File

@@ -40,6 +40,7 @@ describe('ToolConfirmationMessage', () => {
isTrustedFolder: () => true,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
getApprovalMode: () => 'default',
} as unknown as Config;
it('should not display urls if prompt and url are the same', async () => {
@@ -324,6 +325,7 @@ describe('ToolConfirmationMessage', () => {
isTrustedFolder: () => true,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
getApprovalMode: () => 'default',
} as unknown as Config;
const { lastFrame, unmount } = await renderWithProviders(
<ToolConfirmationMessage
@@ -345,6 +347,7 @@ describe('ToolConfirmationMessage', () => {
isTrustedFolder: () => false,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
getApprovalMode: () => 'default',
} as unknown as Config;
const { lastFrame, unmount } = await renderWithProviders(
@@ -380,6 +383,7 @@ describe('ToolConfirmationMessage', () => {
isTrustedFolder: () => true,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
getApprovalMode: () => 'default',
} as unknown as Config;
const { lastFrame, unmount } = await renderWithProviders(
<ToolConfirmationMessage
@@ -406,6 +410,7 @@ describe('ToolConfirmationMessage', () => {
isTrustedFolder: () => true,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
getApprovalMode: () => 'default',
} as unknown as Config;
const { lastFrame, unmount } = await renderWithProviders(
<ToolConfirmationMessage
@@ -447,6 +452,7 @@ describe('ToolConfirmationMessage', () => {
isTrustedFolder: () => true,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
getApprovalMode: () => 'default',
} as unknown as Config;
vi.mocked(useToolActions).mockReturnValue({
confirm: vi.fn(),
@@ -473,6 +479,7 @@ describe('ToolConfirmationMessage', () => {
isTrustedFolder: () => true,
getIdeMode: () => true,
getDisableAlwaysAllow: () => false,
getApprovalMode: () => 'default',
} as unknown as Config;
vi.mocked(useToolActions).mockReturnValue({
confirm: vi.fn(),
@@ -499,6 +506,7 @@ describe('ToolConfirmationMessage', () => {
isTrustedFolder: () => true,
getIdeMode: () => true,
getDisableAlwaysAllow: () => false,
getApprovalMode: () => 'default',
} as unknown as Config;
vi.mocked(useToolActions).mockReturnValue({
confirm: vi.fn(),

View File

@@ -15,6 +15,7 @@ import {
type ToolConfirmationPayload,
ToolConfirmationOutcome,
type EditorType,
ApprovalMode,
hasRedirection,
debugLogger,
} from '@google/gemini-cli-core';
@@ -314,6 +315,31 @@ export const ToolConfirmationMessage: React.FC<
key: 'No, suggest changes (esc)',
});
}
} else if (confirmationDetails.type === 'sandbox_expansion') {
options.push({
label: 'Allow once',
value: ToolConfirmationOutcome.ProceedOnce,
key: 'Allow once',
});
if (isTrustedFolder) {
options.push({
label: 'Allow for this session',
value: ToolConfirmationOutcome.ProceedAlways,
key: 'Allow for this session',
});
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)',
});
} else if (confirmationDetails.type === 'exec') {
options.push({
label: 'Allow once',
@@ -546,6 +572,8 @@ export const ToolConfirmationMessage: React.FC<
if (!confirmationDetails.isModifying) {
question = `Apply this change?`;
}
} else if (confirmationDetails.type === 'sandbox_expansion') {
question = `Allow sandbox expansion for: '${sanitizeForDisplay(confirmationDetails.rootCommand)}'?`;
} else if (confirmationDetails.type === 'exec') {
const executionProps = confirmationDetails;
@@ -573,6 +601,52 @@ export const ToolConfirmationMessage: React.FC<
/>
);
}
} else if (confirmationDetails.type === 'sandbox_expansion') {
const { additionalPermissions } = confirmationDetails;
const readPaths = additionalPermissions?.fileSystem?.read || [];
const writePaths = additionalPermissions?.fileSystem?.write || [];
const network = additionalPermissions?.network;
bodyContent = (
<Box flexDirection="column" padding={1}>
<Text color={theme.text.secondary} italic>
The agent is requesting additional sandbox permissions to execute
this command:
</Text>
<Box paddingY={1}>
<Text color={theme.text.secondary}>
{sanitizeForDisplay(confirmationDetails.command)}
</Text>
</Box>
{network && (
<Box>
<Text color={theme.status.warning}> Network Access</Text>
</Box>
)}
{readPaths.length > 0 && (
<Box flexDirection="column">
<Text color={theme.status.success}> Read Access:</Text>
{readPaths.map((p, i) => (
<Text key={i} color={theme.text.secondary}>
{' '}
{sanitizeForDisplay(p)}
</Text>
))}
</Box>
)}
{writePaths.length > 0 && (
<Box flexDirection="column">
<Text color={theme.status.error}> Write Access:</Text>
{writePaths.map((p, i) => (
<Text key={i} color={theme.text.secondary}>
{' '}
{sanitizeForDisplay(p)}
</Text>
))}
</Box>
)}
</Box>
);
} else if (confirmationDetails.type === 'exec') {
const executionProps = confirmationDetails;
@@ -587,7 +661,8 @@ export const ToolConfirmationMessage: React.FC<
let bodyContentHeight = availableBodyContentHeight();
let warnings: React.ReactNode = null;
if (containsRedirection) {
const isAutoEdit = config.getApprovalMode() === ApprovalMode.AUTO_EDIT;
if (containsRedirection && !isAutoEdit) {
// Calculate lines needed for Note and Tip
const safeWidth = Math.max(terminalWidth, 1);
const noteLength =
@@ -737,6 +812,7 @@ export const ToolConfirmationMessage: React.FC<
isTrustedFolder,
allowPermanentApproval,
settings,
config,
]);
const bodyOverflowDirection: 'top' | 'bottom' =