Compare commits

..

5 Commits

Author SHA1 Message Date
Emily Hedlund 81238f893c merge main and revert files 2026-04-07 12:05:22 -07:00
krishdef7 68fef8745e fix(core): propagate BeforeModel hook model override end-to-end (#24784)
Signed-off-by: krishdef7 <gargkrish06@gmail.com>
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2026-04-07 17:49:26 +00:00
Michael Bleigh e432f7c009 feat(hooks): display hook system messages in UI (#24616) 2026-04-07 17:42:39 +00:00
Alisa 846051f716 Alisa/approve button (#24645) 2026-04-07 16:44:07 +00:00
Tommaso Sciortino 1c22c5b37b Remove flakey test (#24837) 2026-04-07 09:39:15 -07:00
51 changed files with 902 additions and 1014 deletions
+86 -19
View File
@@ -1,7 +1,7 @@
name: 'Evals: PR Evaluation & Regression'
on:
pull_request:
pull_request_target:
types: ['opened', 'synchronize', 'reopened', 'ready_for_review']
paths:
- 'packages/core/src/prompts/**'
@@ -23,13 +23,73 @@ permissions:
actions: 'read'
jobs:
detect-changes:
name: 'Detect Steering Changes'
runs-on: 'gemini-cli-ubuntu-16-core'
# Security: pull_request_target allows secrets, so we must gate carefully.
# Detection should not run code from the fork.
if: "github.repository == 'google-gemini/gemini-cli' && github.event.pull_request.draft == false"
outputs:
SHOULD_RUN: '${{ steps.detect.outputs.SHOULD_RUN }}'
STEERING_DETECTED: '${{ steps.detect.outputs.STEERING_DETECTED }}'
steps:
- name: 'Checkout'
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5
with:
# Check out the trusted code from main for detection
fetch-depth: 0
- name: 'Detect Steering Changes'
id: 'detect'
env:
# Use the PR's head SHA for comparison without checking it out
PR_HEAD_SHA: '${{ github.event.pull_request.head.sha }}'
run: |
# Fetch the fork's PR branch for analysis
git fetch origin pull/${{ github.event.pull_request.number }}/head:pr-head
# Run the trusted script from main
SHOULD_RUN=$(node scripts/changed_prompt.js)
STEERING_DETECTED=$(node scripts/changed_prompt.js --steering-only)
echo "SHOULD_RUN=$SHOULD_RUN" >> "$GITHUB_OUTPUT"
echo "STEERING_DETECTED=$STEERING_DETECTED" >> "$GITHUB_OUTPUT"
- name: 'Notify Approval Required'
if: "steps.detect.outputs.SHOULD_RUN == 'true'"
env:
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
run: |
RUN_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
COMMENT_BODY="### 🛑 Action Required: Evaluation Approval
Steering changes have been detected in this PR. To prevent regressions, a maintainer must approve the evaluation run before this PR can be merged.
**Maintainers:**
1. Go to the [**Workflow Run Summary**]($RUN_URL).
2. Click the yellow **'Review deployments'** button.
3. Select the **'eval-gate'** environment and click **'Approve'**.
Once approved, the evaluation results will be posted here automatically.
<!-- eval-approval-notification -->"
# Check if comment already exists to avoid spamming
COMMENT_ID=$(gh pr view ${{ github.event.pull_request.number }} --json comments --jq '.comments[] | select(.body | contains("<!-- eval-approval-notification -->")) | .url' | grep -oE "[0-9]+$" | head -n 1)
if [ -z "$COMMENT_ID" ]; then
gh pr comment ${{ github.event.pull_request.number }} --body "$COMMENT_BODY"
else
echo "Updating existing notification comment $COMMENT_ID..."
gh api -X PATCH "repos/${{ github.repository }}/issues/comments/$COMMENT_ID" -F body="$COMMENT_BODY"
fi
pr-evaluation:
name: 'Evaluate Steering & Regressions'
needs: 'detect-changes'
if: "needs.detect-changes.outputs.SHOULD_RUN == 'true'"
# Manual approval gate via environment
environment: 'eval-gate'
runs-on: 'gemini-cli-ubuntu-16-core'
if: "github.repository == 'google-gemini/gemini-cli' && (github.event_name != 'pull_request' || (github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository))"
# External contributors' PRs will wait for approval in this environment
environment: |-
${{ (github.event.pull_request.head.repo.full_name == github.repository) && 'internal' || 'external-evals' }}
env:
# CENTRALIZED MODEL LIST
MODEL_LIST: 'gemini-3-flash-preview'
@@ -38,32 +98,40 @@ jobs:
- name: 'Checkout'
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5
with:
# Check out the fork's PR code for the actual evaluation
# This only runs AFTER manual approval
ref: '${{ github.event.pull_request.head.sha }}'
fetch-depth: 0
- name: 'Remove Approval Notification'
# Run even if other steps fail, to ensure we clean up the "Action Required" message
if: 'always()'
env:
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
PR_NUMBER: '${{ github.event.pull_request.number }}'
run: |
echo "Debug: PR_NUMBER is '$PR_NUMBER'"
# Search for the notification comment by its hidden tag
COMMENT_ID=$(gh pr view "$PR_NUMBER" --json comments --jq '.comments[] | select(.body | contains("<!-- eval-approval-notification -->")) | .url' | grep -oE "[0-9]+$" | head -n 1)
if [ -n "$COMMENT_ID" ]; then
echo "Removing notification comment $COMMENT_ID now that run is approved..."
gh api -X DELETE "repos/${{ github.repository }}/issues/comments/$COMMENT_ID"
fi
- name: 'Set up Node.js'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4.4.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
- name: 'Detect Steering Changes'
id: 'detect'
run: |
SHOULD_RUN=$(node scripts/changed_prompt.js)
STEERING_DETECTED=$(node scripts/changed_prompt.js --steering-only)
echo "SHOULD_RUN=$SHOULD_RUN" >> "$GITHUB_OUTPUT"
echo "STEERING_DETECTED=$STEERING_DETECTED" >> "$GITHUB_OUTPUT"
- name: 'Install dependencies'
if: "steps.detect.outputs.SHOULD_RUN == 'true'"
run: 'npm ci'
- name: 'Build project'
if: "steps.detect.outputs.SHOULD_RUN == 'true'"
run: 'npm run build'
- name: 'Analyze PR Content (Guidance)'
if: "steps.detect.outputs.STEERING_DETECTED == 'true'"
if: "needs.detect-changes.outputs.STEERING_DETECTED == 'true'"
id: 'analysis'
env:
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
@@ -81,7 +149,6 @@ jobs:
fi
- name: 'Execute Regression Check'
if: "steps.detect.outputs.SHOULD_RUN == 'true'"
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
@@ -96,7 +163,7 @@ jobs:
fi
- name: 'Post or Update PR Comment'
if: "always() && (steps.detect.outputs.STEERING_DETECTED == 'true' || env.REPORT_FILE != '')"
if: "always() && (needs.detect-changes.outputs.STEERING_DETECTED == 'true' || env.REPORT_FILE != '')"
env:
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
run: |
@@ -107,7 +174,7 @@ jobs:
echo ""
fi
if [[ "${{ steps.detect.outputs.STEERING_DETECTED }}" == "true" ]]; then
if [[ "${{ needs.detect-changes.outputs.STEERING_DETECTED }}" == "true" ]]; then
echo "### 🧠 Model Steering Guidance"
echo ""
echo "This PR modifies files that affect the model's behavior (prompts, tools, or instructions)."
+2 -19
View File
@@ -5,8 +5,8 @@
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { writeFileSync, readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { TestRig } from './test-helper.js';
// BOM encoders
@@ -116,21 +116,4 @@ describe('BOM end-to-end integraion', () => {
'BOM_OK UTF-32BE',
);
});
it('Can describe a PNG file', async () => {
const imagePath = resolve(
process.cwd(),
'docs/assets/gemini-screenshot.png',
);
const imageContent = readFileSync(imagePath);
const filename = 'gemini-screenshot.png';
writeFileSync(join(rig.testDir!, filename), imageContent);
const prompt = `What is shown in the image ${filename}?`;
const output = await rig.run({ args: prompt });
await rig.waitForToolCall('read_file');
const lower = output.toLowerCase();
// The response is non-deterministic, so we just check for some
// keywords that are very likely to be in the response.
expect(lower.includes('gemini')).toBeTruthy();
});
});
+1 -2
View File
@@ -3796,8 +3796,7 @@ describe('loadCliConfig mcpEnabled', () => {
]);
const config = await loadCliConfig(settings, 'test-session', argv);
config.setActiveExtensionContext('ext-plan');
expect(config.getPlansDir()).toContain('ext-plans-dir');
expect(config.storage.getPlansDir()).toContain('ext-plans-dir');
});
it('should NOT use plan directory from active extension when user has specified one', async () => {
+6 -8
View File
@@ -610,12 +610,9 @@ export async function loadCliConfig(
});
await extensionManager.loadExtensions();
const extensionPlanDirs: Record<string, string> = {};
for (const ext of extensionManager.getExtensions()) {
if (ext.isActive && ext.plan?.directory) {
extensionPlanDirs[ext.name] = ext.plan.directory;
}
}
const extensionPlanSettings = extensionManager
.getExtensions()
.find((ext) => ext.isActive && ext.plan?.directory)?.plan;
const experimentalJitContext = settings.experimental.jitContext;
@@ -985,8 +982,9 @@ export async function loadCliConfig(
plan: settings.general?.plan?.enabled ?? true,
tracker: settings.experimental?.taskTracker,
directWebFetch: settings.experimental?.directWebFetch,
planSettings: settings.general?.plan,
extensionPlanDirs,
planSettings: settings.general?.plan?.directory
? settings.general.plan
: (extensionPlanSettings ?? settings.general?.plan),
enableEventDrivenScheduler: true,
skillsSupport: settings.skills?.enabled ?? true,
disabledSkills: settings.skills?.disabled,
@@ -23,8 +23,6 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
isInteractive: vi.fn(() => false),
isInitialized: vi.fn(() => true),
setTerminalBackground: vi.fn(),
setActiveExtensionContext: vi.fn(),
hasExtensionPlanDir: vi.fn(() => true),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'),
initialize: vi.fn().mockResolvedValue(undefined),
@@ -3195,7 +3195,9 @@ describe('AppContainer State Management', () => {
);
unmount();
});
});
describe('Overflow Hint Handling', () => {
beforeEach(() => {
vi.useFakeTimers();
});
+18 -3
View File
@@ -36,9 +36,11 @@ import {
type ConfirmationRequest,
type PermissionConfirmationRequest,
type QuotaStats,
MessageType,
StreamingState,
type HistoryItemInfo,
} from './types.js';
import { checkPermissions } from './hooks/atCommandProcessor.js';
import { MessageType, StreamingState } from './types.js';
import { ToolActionsProvider } from './contexts/ToolActionsContext.js';
import { MouseProvider } from './contexts/MouseContext.js';
import { ScrollProvider } from './contexts/ScrollProvider.js';
@@ -51,6 +53,7 @@ import {
type UserTierId,
type GeminiUserTier,
type UserFeedbackPayload,
type HookSystemMessagePayload,
type AgentDefinition,
type ApprovalMode,
IdeClient,
@@ -1347,11 +1350,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
isToolExecuting(pendingHistoryItems);
if (isSlash && isAgentRunning) {
const parsedCommand = parseSlashCommand(
const { commandToExecute } = parseSlashCommand(
submittedValue,
slashCommands ?? [],
);
const commandToExecute = parsedCommand.commandToExecute;
if (commandToExecute?.isSafeConcurrent) {
void handleSlashCommand(submittedValue);
addInput(submittedValue);
@@ -2112,7 +2114,19 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
};
const handleHookSystemMessage = (payload: HookSystemMessagePayload) => {
historyManager.addItem(
{
type: MessageType.INFO,
text: payload.message,
source: payload.hookName,
} as HistoryItemInfo,
Date.now(),
);
};
coreEvents.on(CoreEvent.UserFeedback, handleUserFeedback);
coreEvents.on(CoreEvent.HookSystemMessage, handleHookSystemMessage);
// Flush any messages that happened during startup before this component
// mounted.
@@ -2120,6 +2134,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
return () => {
coreEvents.off(CoreEvent.UserFeedback, handleUserFeedback);
coreEvents.off(CoreEvent.HookSystemMessage, handleHookSystemMessage);
};
}, [historyManager]);
@@ -45,7 +45,6 @@ describe('clearCommand', () => {
fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
}),
setActiveExtensionContext: vi.fn(),
injectionService: {
clear: mockHintClear,
},
+1 -2
View File
@@ -30,9 +30,8 @@ export const clearCommand: SlashCommand = {
await hookSystem.fireSessionEndEvent(SessionEndReason.Clear);
}
// Reset user steering hints and extension context
// Reset user steering hints
config?.injectionService.clear();
config?.setActiveExtensionContext(undefined);
// Start a new conversation recording with a new session ID
// We MUST do this before calling resetChat() so the new ChatRecordingService
@@ -56,9 +56,8 @@ describe('planCommand', () => {
config: {
isPlanEnabled: vi.fn(),
setApprovalMode: vi.fn(),
getApprovalMode: vi.fn(),
getApprovedPlanPath: vi.fn(),
getPlansDir: vi.fn(),
getApprovalMode: vi.fn(),
getFileSystemService: vi.fn(),
storage: {
getPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'),
+1 -1
View File
@@ -82,7 +82,7 @@ export const planCommand: SlashCommand = {
try {
const content = await processSingleFileContent(
approvedPlanPath,
config.getPlansDir(),
config.storage.getPlansDir(),
config.getFileSystemService(),
);
const fileName = path.basename(approvedPlanPath);
@@ -134,6 +134,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
<InfoMessage
text={itemForDisplay.text}
secondaryText={itemForDisplay.secondaryText}
source={itemForDisplay.source}
icon={itemForDisplay.icon}
color={itemForDisplay.color}
marginBottom={itemForDisplay.marginBottom}
@@ -12,6 +12,7 @@ import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
interface InfoMessageProps {
text: string;
secondaryText?: string;
source?: string;
icon?: string;
color?: string;
marginBottom?: number;
@@ -20,6 +21,7 @@ interface InfoMessageProps {
export const InfoMessage: React.FC<InfoMessageProps> = ({
text,
secondaryText,
source,
icon,
color,
marginBottom,
@@ -40,6 +42,9 @@ export const InfoMessage: React.FC<InfoMessageProps> = ({
{index === text.split('\n').length - 1 && secondaryText && (
<Text color={theme.text.secondary}> {secondaryText}</Text>
)}
{index === text.split('\n').length - 1 && source && (
<Text color={theme.text.secondary}> [{source}]</Text>
)}
</Text>
))}
</Box>
@@ -993,103 +993,4 @@ describe('useSlashCommandProcessor', () => {
expect(result.current.slashCommands).toEqual([newCommand]),
);
});
describe('Active Extension Context Switching', () => {
it('sets active extension context when a command with a plan dir is executed', async () => {
const extensionCommand = createTestCommand({
name: 'conductor:setup',
extensionName: 'conductor',
action: vi.fn(),
});
const spyHasPlanDir = vi
.spyOn(mockConfig, 'hasExtensionPlanDir')
.mockReturnValue(true);
const spySetContext = vi.spyOn(mockConfig, 'setActiveExtensionContext');
const hook = await setupProcessorHook({
builtinCommands: [extensionCommand],
});
await waitFor(() => expect(hook.current.slashCommands!.length).toBe(1));
await act(async () => {
await hook.current.handleSlashCommand('/conductor:setup');
});
expect(spyHasPlanDir).toHaveBeenCalledWith('conductor');
expect(spySetContext).toHaveBeenCalledWith('conductor');
});
it('clears active extension context when a command WITHOUT a plan dir is executed', async () => {
const extensionCommand = createTestCommand({
name: 'other:cmd',
extensionName: 'other',
action: vi.fn(),
});
const spyHasPlanDir = vi
.spyOn(mockConfig, 'hasExtensionPlanDir')
.mockReturnValue(false);
const spySetContext = vi.spyOn(mockConfig, 'setActiveExtensionContext');
const hook = await setupProcessorHook({
builtinCommands: [extensionCommand],
});
await waitFor(() => expect(hook.current.slashCommands!.length).toBe(1));
await act(async () => {
await hook.current.handleSlashCommand('/other:cmd');
});
expect(spyHasPlanDir).toHaveBeenCalledWith('other');
expect(spySetContext).toHaveBeenCalledWith(undefined);
});
it('handles a sequence of context switches between extensions and default plan mode', async () => {
const extA = createTestCommand({
name: 'extA',
extensionName: 'extA',
action: vi.fn(),
});
const extB = createTestCommand({
name: 'extB',
extensionName: 'extB',
action: vi.fn(),
});
const planCmd = createTestCommand({
name: 'plan',
action: vi.fn(),
});
const spySetContext = vi.spyOn(mockConfig, 'setActiveExtensionContext');
vi.spyOn(mockConfig, 'hasExtensionPlanDir').mockReturnValue(true);
const hook = await setupProcessorHook({
builtinCommands: [extA, extB, planCmd],
});
await waitFor(() => expect(hook.current.slashCommands!.length).toBe(3));
// 1. Run Ext A
await act(async () => {
await hook.current.handleSlashCommand('/extA');
});
expect(spySetContext).toHaveBeenLastCalledWith('extA');
// 2. Run Ext B
await act(async () => {
await hook.current.handleSlashCommand('/extB');
});
expect(spySetContext).toHaveBeenLastCalledWith('extB');
// 3. Run /help (Concurrent global command)
spySetContext.mockClear();
await act(async () => {
await hook.current.handleSlashCommand('/help');
});
// A concurrent command should NOT modify the active extension context
expect(spySetContext).not.toHaveBeenCalled();
});
});
});
@@ -368,17 +368,8 @@ export const useSlashCommandProcessor = (
commandToExecute,
args,
canonicalPath: resolvedCommandPath,
extensionContext,
} = parseSlashCommand(trimmed, commands);
if (config && commandToExecute && !commandToExecute.isSafeConcurrent) {
config.setActiveExtensionContext(
extensionContext && config.hasExtensionPlanDir(extensionContext)
? extensionContext
: undefined,
);
}
// If the input doesn't match any known command, check if MCP servers
// are still loading (the command might come from an MCP server).
// Otherwise, treat it as regular text input (e.g. file paths like
+1
View File
@@ -174,6 +174,7 @@ export type HistoryItemInfo = HistoryItemBase & {
type: 'info';
text: string;
secondaryText?: string;
source?: string;
icon?: string;
color?: string;
marginBottom?: number;
+1 -5
View File
@@ -10,7 +10,6 @@ export type ParsedSlashCommand = {
commandToExecute: SlashCommand | undefined;
args: string;
canonicalPath: string[];
extensionContext?: string;
};
/**
@@ -70,8 +69,6 @@ export const parseSlashCommand = (
const args = parts.slice(pathIndex).join(' ');
const extensionContext = commandToExecute?.extensionName;
// Backtrack if the matched (sub)command doesn't take arguments but some were provided,
// AND the parent command is capable of handling them.
if (
@@ -85,9 +82,8 @@ export const parseSlashCommand = (
commandToExecute: parentCommand,
args: parts.slice(pathIndex - 1).join(' '),
canonicalPath: canonicalPath.slice(0, -1),
extensionContext: parentCommand.extensionName,
};
}
return { commandToExecute, args, canonicalPath, extensionContext };
return { commandToExecute, args, canonicalPath };
};
View File
View File
+29 -176
View File
@@ -3275,9 +3275,6 @@ describe('Plans Directory Initialization', () => {
debugMode: false,
model: 'test-model',
cwd: '/tmp/test',
planSettings: {
directory: 'plans',
},
};
beforeEach(() => {
@@ -3286,12 +3283,11 @@ describe('Plans Directory Initialization', () => {
afterEach(() => {
vi.mocked(fs.promises.mkdir).mockRestore();
vi.mocked(fs.mkdirSync).mockRestore?.();
vi.mocked(fs.existsSync).mockReturnValue(true); // Reset to default mock behavior
vi.mocked(fs.promises.access).mockRestore?.();
});
it('should not eagerly create plans directory during initialization', async () => {
vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
it('should add plans directory to workspace context if it exists', async () => {
vi.spyOn(fs.promises, 'access').mockResolvedValue(undefined);
const config = new Config({
...baseParams,
plan: true,
@@ -3299,175 +3295,34 @@ describe('Plans Directory Initialization', () => {
await config.initialize();
// Should NOT create the directory eagerly
expect(fs.mkdirSync).not.toHaveBeenCalled();
// Using storage directly to avoid triggering creation
const plansDir = config.storage.getPlansDir();
// Should NOT create the directory eagerly
expect(fs.promises.mkdir).not.toHaveBeenCalled();
// Should check if it exists
expect(fs.promises.access).toHaveBeenCalledWith(plansDir);
const context = config.getWorkspaceContext();
expect(context.getDirectories()).toContain(plansDir);
});
it('should NOT add plans directory to workspace context if it does not exist', async () => {
vi.spyOn(fs.promises, 'access').mockRejectedValue({ code: 'ENOENT' });
const config = new Config({
...baseParams,
plan: true,
});
await config.initialize();
const plansDir = config.storage.getPlansDir();
expect(fs.promises.mkdir).not.toHaveBeenCalled();
expect(fs.promises.access).toHaveBeenCalledWith(plansDir);
const context = config.getWorkspaceContext();
expect(context.getDirectories()).not.toContain(plansDir);
});
it('should create plans directory and add it to workspace context when getPlansDir is called', async () => {
vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
const config = new Config({
...baseParams,
plan: true,
});
await config.initialize();
const plansDir = config.getPlansDir();
expect(fs.mkdirSync).toHaveBeenCalledWith(plansDir, {
recursive: true,
});
const context = config.getWorkspaceContext();
expect(context.getDirectories()).toContain(plansDir);
});
it('should gracefully handle existing directories by relying on mkdirSync recursive: true', async () => {
vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
const config = new Config({
...baseParams,
plan: true,
});
await config.initialize();
const plansDir = config.getPlansDir();
// mkdirSync should be called unconditionally
expect(fs.mkdirSync).toHaveBeenCalledWith(plansDir, { recursive: true });
// It MUST still register the directory
const context = config.getWorkspaceContext();
expect(context.getDirectories()).toContain(plansDir);
});
it('should log a warning if the plan directory path is blocked by an existing file (EEXIST)', async () => {
const writeSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
vi.spyOn(fs, 'mkdirSync').mockImplementation(() => {
const err = new Error('File exists') as NodeJS.ErrnoException;
err.code = 'EEXIST';
throw err;
});
const config = new Config({
...baseParams,
plan: true,
});
await config.initialize();
config.getPlansDir();
expect(writeSpy).toHaveBeenCalledWith(
expect.stringMatching(
/Failed to initialize active plan directory.*File exists/,
),
);
writeSpy.mockRestore();
});
it('should throw a security violation and verify mkdirSync runs before realpathSync (TOCTOU mitigation)', async () => {
const callOrder: string[] = [];
vi.spyOn(fs, 'mkdirSync').mockImplementation(() => {
callOrder.push('mkdirSync');
return undefined;
});
const config = new Config({
...baseParams,
plan: true,
});
await config.initialize();
// Bypass Storage check so we can specifically test Config's check
vi.spyOn(config.storage, 'getPlansDir').mockReturnValue('/tmp/test/plans');
const realpathSpy = vi
.spyOn(fs, 'realpathSync')
.mockImplementation((p: fs.PathLike) => {
const pStr = p.toString();
// Ignore the calls from storage/initialization
if (pStr.includes('plans')) {
callOrder.push('realpathSync');
return '/outside/the/project/root/plans';
}
return pStr;
});
try {
expect(() => config.getPlansDir()).toThrow(
/Security violation: Resolved plan directory.*is outside both the project root.*and the global configuration directory/,
);
expect(callOrder).toEqual(['mkdirSync', 'realpathSync']);
} finally {
realpathSpy.mockRestore();
}
});
it('should log a warning if mkdirSync fails during getPlansDir (e.g. EACCES)', async () => {
const writeSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
vi.spyOn(fs, 'mkdirSync').mockImplementation(() => {
const err = new Error('Permission denied') as NodeJS.ErrnoException;
err.code = 'EACCES';
throw err;
});
const config = new Config({
...baseParams,
plan: true,
});
await config.initialize();
config.getPlansDir();
expect(writeSpy).toHaveBeenCalledWith(
expect.stringMatching(
/Failed to initialize active plan directory.*Permission denied/,
),
);
writeSpy.mockRestore();
});
it('should deduplicate and cache when multiple extensions (or default) use the same directory', async () => {
vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
const config = new Config({
...baseParams,
plan: true,
});
await config.initialize();
// 1. Call for Default Plan Dir
const defaultDir = config.getPlansDir();
expect(fs.mkdirSync).toHaveBeenCalledTimes(1);
// 2. Mock an extension that happens to use the SAME directory string
vi.spyOn(
config as unknown as {
getActiveExtensionPlanDir: () => string | undefined;
},
'getActiveExtensionPlanDir',
).mockReturnValue(
'plans', // This will resolve to the same path as the default in our mock setup
);
const extDir = config.getPlansDir();
// It should be the same path
expect(extDir).toBe(defaultDir);
// It should NOT have called mkdirSync a second time
expect(fs.mkdirSync).toHaveBeenCalledTimes(1);
});
it('should NOT create plans directory or add it to workspace context when plan is disabled', async () => {
vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
const config = new Config({
...baseParams,
plan: false,
@@ -3475,12 +3330,10 @@ describe('Plans Directory Initialization', () => {
await config.initialize();
// Even if getPlansDir is called manually, it should NOT create the directory
const plansDir = config.getPlansDir();
expect(fs.mkdirSync).not.toHaveBeenCalled();
expect(config.getWorkspaceContext().getDirectories()).not.toContain(
plansDir,
);
const plansDir = config.storage.getPlansDir();
expect(fs.promises.mkdir).not.toHaveBeenCalledWith(plansDir, {
recursive: true,
});
});
});
+16 -131
View File
@@ -6,9 +6,6 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { LRUCache } from 'mnemonist';
import { SecurityError } from '../core/errors.js';
import { isSubpath, resolveToRealPath } from '../utils/paths.js';
import { SandboxPolicyManager } from '../policy/sandboxPolicyManager.js';
import { inspect } from 'node:util';
import process from 'node:process';
@@ -171,6 +168,7 @@ import { SkillManager, type SkillDefinition } from '../skills/skillManager.js';
import { startupProfiler } from '../telemetry/startupProfiler.js';
import type { AgentDefinition } from '../agents/types.js';
import { fetchAdminControls } from '../code_assist/admin/admin_controls.js';
import { isSubpath, resolveToRealPath } from '../utils/paths.js';
import { InjectionService } from './injectionService.js';
import { ExecutionLifecycleService } from '../services/executionLifecycleService.js';
import { WORKSPACE_POLICY_TIER } from '../policy/config.js';
@@ -712,7 +710,6 @@ export interface ConfigParameters {
plan?: boolean;
tracker?: boolean;
planSettings?: PlanSettings;
extensionPlanDirs?: Record<string, string>;
worktreeSettings?: WorktreeSettings;
modelSteering?: boolean;
onModelChange?: (model: string) => void;
@@ -776,16 +773,6 @@ export class Config implements McpContext, AgentLoopContext {
private readonly extensionsEnabled: boolean;
private mcpServers: Record<string, MCPServerConfig> | undefined;
private readonly mcpEnablementCallbacks?: McpEnablementCallbacks;
/**
* The persistent context used to route tools (e.g. plans, tasks) to the correct
* extension directory.
* This is a persistent session state (similar to `approvalMode`). It remains active
* across multiple LLM turns until explicitly changed by another command.
*/
private activeExtensionContext?: string;
private initializedPlanDirs = new Set<string>();
private globalGeminiDirCache = new LRUCache<string, string | undefined>(1);
private readonly extensionPlanDirs: Record<string, string>;
private userMemory: string | HierarchicalMemory;
private geminiMdFileCount: number;
private geminiMdFilePaths: string[];
@@ -921,8 +908,8 @@ export class Config implements McpContext, AgentLoopContext {
private readonly acceptRawOutputRisk: boolean;
private readonly dynamicModelConfiguration: boolean;
private pendingIncludeDirectories: string[];
private readonly enableHooks: boolean;
private readonly enableHooksUI: boolean;
private readonly enableHooks: boolean;
private hooks: { [K in HookEventName]?: HookDefinition[] } | undefined;
private projectHooks:
@@ -1049,7 +1036,6 @@ export class Config implements McpContext, AgentLoopContext {
this.mcpServerCommand = params.mcpServerCommand;
this.mcpServers = params.mcpServers;
this.mcpEnablementCallbacks = params.mcpEnablementCallbacks;
this.extensionPlanDirs = params.extensionPlanDirs ?? {};
this.mcpEnabled = params.mcpEnabled ?? true;
this.extensionsEnabled = params.extensionsEnabled ?? true;
this.allowedMcpServers = params.allowedMcpServers ?? [];
@@ -1419,6 +1405,20 @@ export class Config implements McpContext, AgentLoopContext {
this.workspaceContext.addDirectory(dir);
}
// Add plans directory to workspace context for plan file storage
if (this.planEnabled) {
const plansDir = this.storage.getPlansDir();
try {
await fs.promises.access(plansDir);
this.workspaceContext.addDirectory(plansDir);
} catch {
// Directory does not exist yet, so we don't add it to the workspace context.
// It will be created when the first plan is written. Since custom plan
// directories must be within the project root, they are automatically
// covered by the project-wide file discovery once created.
}
}
// Initialize centralized FileDiscoveryService
const discoverToolsHandle = startupProfiler.start('discover_tools');
this.getFileService();
@@ -2238,121 +2238,6 @@ export class Config implements McpContext, AgentLoopContext {
return this.mcpEnabled;
}
getActiveExtensionContext(): string | undefined {
return this.activeExtensionContext;
}
setActiveExtensionContext(context: string | undefined): void {
this.activeExtensionContext = context;
}
hasExtensionPlanDir(name: string): boolean {
return !!this.extensionPlanDirs[name];
}
getActiveExtensionPlanDir(): string | undefined {
const context = this.getActiveExtensionContext();
if (context) {
return this.extensionPlanDirs[context];
}
return undefined;
}
getPlansDir(): string {
const plansDir = this.storage.getPlansDir(this.getActiveExtensionPlanDir());
if (this.initializedPlanDirs.has(plansDir)) {
return plansDir;
}
const realProjectRoot = this.storage.getRealProjectRoot();
let realGlobalGeminiDir = this.globalGeminiDirCache.get('default');
if (!this.globalGeminiDirCache.has('default')) {
try {
realGlobalGeminiDir = resolveToRealPath(Storage.getGlobalGeminiDir());
} catch {
// If we can't securely resolve the global config dir (e.g. strict EACCES permissions on ~/),
// we gracefully degrade by not allowing it as a valid security boundary for plans.
realGlobalGeminiDir = undefined;
}
this.globalGeminiDirCache.set('default', realGlobalGeminiDir);
}
// 1. Lexical security check (before any filesystem mutation or return)
// We compare purely unresolved paths here to avoid static analyzer warnings about mixing resolved and unresolved paths.
// The physical security check happens AFTER mkdirSync.
const unresolvedProjectRoot = path.resolve(this.storage.getProjectRoot());
const unresolvedGlobalDir = path.resolve(Storage.getGlobalGeminiDir());
const unresolvedPlansDir = path.resolve(plansDir);
if (
!isSubpath(unresolvedProjectRoot, unresolvedPlansDir) &&
!isSubpath(unresolvedGlobalDir, unresolvedPlansDir)
) {
throw new SecurityError(
`Security violation: Plan directory '${unresolvedPlansDir}' is outside both the project root '${unresolvedProjectRoot}' and the global configuration directory.`,
);
}
// 2. We only attempt to physically create the directory if plan mode is enabled
if (this.planEnabled) {
let mkdirError: unknown;
try {
fs.mkdirSync(plansDir, { recursive: true });
} catch (e: unknown) {
mkdirError = e;
}
// 3. Physical security check (after creation, to mitigate TOCTOU symlink attacks)
let realPlansDir: string;
try {
realPlansDir = resolveToRealPath(plansDir);
} catch (e: unknown) {
if (
mkdirError &&
e instanceof Error &&
'code' in e &&
e.code === 'ENOENT'
) {
const errorMessage =
mkdirError instanceof Error
? mkdirError.message
: String(mkdirError);
process.stderr.write(
`Failed to initialize active plan directory at '${plansDir}': ${errorMessage}\n`,
);
this.initializedPlanDirs.add(plansDir);
return plansDir;
}
throw new SecurityError(
`Security violation: Could not securely resolve plan directory '${plansDir}'. System error: ${e instanceof Error ? e.message : String(e)}`,
);
}
if (
!isSubpath(realProjectRoot, realPlansDir) &&
(!realGlobalGeminiDir || !isSubpath(realGlobalGeminiDir, realPlansDir))
) {
throw new SecurityError(
`Security violation: Resolved plan directory '${realPlansDir}' is outside both the project root '${realProjectRoot}' and the global configuration directory.`,
);
}
if (mkdirError) {
const errorMessage =
mkdirError instanceof Error ? mkdirError.message : String(mkdirError);
process.stderr.write(
`Failed to initialize active plan directory at '${plansDir}': ${errorMessage}\n`,
);
} else {
this.workspaceContext.addDirectory(realPlansDir);
}
}
this.initializedPlanDirs.add(plansDir);
return plansDir;
}
getMcpEnablementCallbacks(): McpEnablementCallbacks | undefined {
return this.mcpEnablementCallbacks;
}
+22 -18
View File
@@ -24,7 +24,7 @@ vi.mock('fs', async (importOriginal) => {
});
import { Storage } from './storage.js';
import { GEMINI_DIR, homedir } from '../utils/paths.js';
import { GEMINI_DIR, homedir, resolveToRealPath } from '../utils/paths.js';
import { ProjectRegistry } from './projectRegistry.js';
import { StorageMigration } from './storageMigration.js';
@@ -103,8 +103,8 @@ describe('Storage - Security', () => {
});
describe('Storage additional helpers', () => {
const projectRoot = path.resolve('/tmp/project');
let storage = new Storage(projectRoot);
const projectRoot = resolveToRealPath(path.resolve('/tmp/project'));
const storage = new Storage(projectRoot);
beforeEach(() => {
ProjectRegistry.prototype.getShortId = vi
@@ -306,6 +306,12 @@ describe('Storage additional helpers', () => {
customDir: '.my-plans',
expected: path.resolve(projectRoot, '.my-plans'),
},
{
name: 'custom absolute path outside throws',
customDir: path.resolve('/absolute/path/to/plans'),
expected: '',
expectedError: `Custom plans directory '${path.resolve('/absolute/path/to/plans')}' resolves to '${path.resolve('/absolute/path/to/plans')}', which is outside the project root '${resolveToRealPath(projectRoot)}'.`,
},
{
name: 'absolute path that happens to be inside project root',
customDir: path.join(projectRoot, 'internal-plans'),
@@ -326,39 +332,37 @@ describe('Storage additional helpers', () => {
customDir: undefined,
expected: () => storage.getProjectTempPlansDir(),
},
{
name: 'escaping relative path throws',
customDir: '../escaped-plans',
expected: '',
expectedError: `Custom plans directory '../escaped-plans' resolves to '${resolveToRealPath(path.resolve(projectRoot, '../escaped-plans'))}', which is outside the project root '${resolveToRealPath(projectRoot)}'.`,
},
{
name: 'hidden directory starting with ..',
customDir: '..plans',
expected: path.resolve(projectRoot, '..plans'),
},
{
name: 'non-existent plan dir in a symlinked project root',
customDir: 'new-plans',
name: 'security escape via symbolic link throws',
customDir: 'symlink-to-outside',
setup: () => {
vi.mocked(fs.realpathSync).mockImplementation((p: fs.PathLike) => {
const pStr = p.toString();
if (pStr === projectRoot) {
return '/private/tmp/project';
if (p.toString().includes('symlink-to-outside')) {
return path.resolve('/outside/project/root');
}
if (pStr.includes('new-plans')) {
const err = new Error('ENOENT') as NodeJS.ErrnoException;
err.code = 'ENOENT';
throw err;
}
return pStr;
return p.toString();
});
return () => vi.mocked(fs.realpathSync).mockRestore();
},
expected: path.resolve(projectRoot, 'new-plans'),
expected: '',
expectedError: `Custom plans directory 'symlink-to-outside' resolves to '${path.resolve('/outside/project/root')}', which is outside the project root '${resolveToRealPath(projectRoot)}'.`,
},
];
testCases.forEach(({ name, customDir, expected, expectedError, setup }) => {
it(`should handle ${name}`, async () => {
const cleanup = setup?.();
if (setup) {
storage = new Storage(projectRoot, 'test-session');
}
try {
if (name.includes('default behavior')) {
await storage.initialize();
+18 -10
View File
@@ -12,11 +12,13 @@ import {
GEMINI_DIR,
homedir,
GOOGLE_ACCOUNTS_FILENAME,
isSubpath,
resolveToRealPath,
normalizePath,
} from '../utils/paths.js';
import { ProjectRegistry } from './projectRegistry.js';
import { StorageMigration } from './storageMigration.js';
export const OAUTH_FILE = 'oauth_creds.json';
const TMP_DIR_NAME = 'tmp';
const BIN_DIR_NAME = 'bin';
@@ -30,16 +32,10 @@ export class Storage {
private projectIdentifier: string | undefined;
private initPromise: Promise<void> | undefined;
private customPlansDir: string | undefined;
private readonly realProjectRoot: string;
constructor(targetDir: string, sessionId?: string) {
this.targetDir = targetDir;
this.sessionId = sessionId;
this.realProjectRoot = resolveToRealPath(targetDir);
}
getRealProjectRoot(): string {
return this.realProjectRoot;
}
setCustomPlansDir(dir: string | undefined): void {
@@ -324,10 +320,22 @@ export class Storage {
return path.join(this.getProjectTempDir(), 'tracker');
}
getPlansDir(extensionPlanDir?: string): string {
const customDir = extensionPlanDir || this.customPlansDir;
if (customDir) {
return path.resolve(this.getProjectRoot(), customDir);
getPlansDir(): string {
if (this.customPlansDir) {
const resolvedPath = path.resolve(
this.getProjectRoot(),
this.customPlansDir,
);
const realProjectRoot = resolveToRealPath(this.getProjectRoot());
const realResolvedPath = resolveToRealPath(resolvedPath);
if (!isSubpath(realProjectRoot, realResolvedPath)) {
throw new Error(
`Custom plans directory '${this.customPlansDir}' resolves to '${realResolvedPath}', which is outside the project root '${realProjectRoot}'.`,
);
}
return resolvedPath;
}
return this.getProjectTempPlansDir();
}
@@ -572,7 +572,7 @@ For example:
# Active Approval Mode: Plan
You are operating in **Plan Mode**. Your goal is to produce an implementation plan in \`/tmp/plans/\` and get user approval before editing source code.
You are operating in **Plan Mode**. Your goal is to produce an implementation plan in \`/tmp/project-temp/plans/\` and get user approval before editing source code.
## Available Tools
The following tools are available in Plan Mode:
@@ -588,8 +588,8 @@ The following tools are available in Plan Mode:
</available_tools>
## Rules
1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`/tmp/plans/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a plan and get approval.
2. **Write Constraint:** \`write_file\` and \`replace\` may ONLY be used to write .md plan files to \`/tmp/plans/\`. They cannot modify source code.
1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`/tmp/project-temp/plans/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a plan and get approval.
2. **Write Constraint:** \`write_file\` and \`replace\` may ONLY be used to write .md plan files to \`/tmp/project-temp/plans/\`. They cannot modify source code.
3. **Efficiency:** Autonomously combine discovery and drafting phases to minimize conversational turns. If the request is ambiguous, use \`ask_user\` to clarify. Use multi-select to offer flexibility and include detailed descriptions for each option to help the user understand the implications of their choice.
4. **Inquiries and Directives:** Distinguish between Inquiries and Directives to minimize unnecessary planning.
- **Inquiries:** If the request is an **Inquiry** (e.g., "How does X work?"), answer directly. DO NOT create a plan.
@@ -612,7 +612,7 @@ The depth of your consultation should be proportional to the task's complexity.
**CRITICAL:** You MUST NOT proceed to Step 3 (Draft) or Step 4 (Review & Approval) in the same turn as your initial strategy proposal. You MUST wait for user feedback and reach a clear agreement before drafting or submitting the plan.
### 3. Draft
Write the implementation plan to \`/tmp/plans/\`. The plan's structure adapts to the task:
Write the implementation plan to \`/tmp/project-temp/plans/\`. The plan's structure adapts to the task:
- **Simple Tasks:** Include a bulleted list of specific **Changes** and **Verification** steps.
- **Standard Tasks:** Include an **Objective**, **Key Files & Context**, **Implementation Steps**, and **Verification & Testing**.
- **Complex Tasks:** Include **Background & Motivation**, **Scope & Impact**, **Proposed Solution**, **Alternatives Considered**, a phased **Implementation Plan**, **Verification**, and **Migration & Rollback** strategies.
-11
View File
@@ -1,11 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export class SecurityError extends Error {
constructor(message: string) {
super(message);
this.name = 'SecurityError';
}
}
+15
View File
@@ -597,6 +597,21 @@ export class GeminiChat {
);
}
if (beforeModelResult.modifiedModel) {
modelToUse = resolveModel(
beforeModelResult.modifiedModel,
useGemini3_1,
useGemini3_1FlashLite,
false,
hasAccessToPreview,
this.context.config,
);
lastModelToUse = modelToUse;
// Re-evaluate contentsToUse based on the new model's feature support
contentsToUse = supportsModernFeatures(modelToUse)
? [...contentsForPreviewModel]
: [...requestContents];
}
if (beforeModelResult.modifiedConfig) {
Object.assign(config, beforeModelResult.modifiedConfig);
}
+1 -3
View File
@@ -90,10 +90,9 @@ describe('Core System Prompt (prompts.ts)', () => {
getToolRegistry: vi.fn().mockReturnValue(mockRegistry),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
getSandboxEnabled: vi.fn().mockReturnValue(false),
getPlansDir: vi.fn().mockReturnValue('/tmp/plans'),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'),
getPlansDir: vi.fn().mockReturnValue('/tmp/plans'),
getPlansDir: vi.fn().mockReturnValue('/tmp/project-temp/plans'),
getProjectTempTrackerDir: vi
.fn()
.mockReturnValue('/mock/.gemini/tmp/session/tracker'),
@@ -424,7 +423,6 @@ describe('Core System Prompt (prompts.ts)', () => {
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
getSandboxEnabled: vi.fn().mockReturnValue(false),
getPlansDir: vi.fn().mockReturnValue('/tmp/plans'),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'),
},
@@ -458,6 +458,15 @@ export class HookEventHandler {
);
logHookCall(this.context.config, hookCallEvent);
// Emit structured system message event for UI display
if (result.output?.systemMessage && result.outputFormat === 'json') {
coreEvents.emitHookSystemMessage({
hookName,
eventName,
message: result.output.systemMessage,
});
}
}
// Log individual errors
+15 -2
View File
@@ -204,7 +204,11 @@ describe('HookRunner', () => {
};
it('should execute command hook successfully', async () => {
const mockOutput = { decision: 'allow', reason: 'All good' };
const mockOutput = {
decision: 'allow',
reason: 'All good',
format: 'json',
};
// Mock successful execution
mockSpawn.mockStdoutOn.mockImplementation(
@@ -623,6 +627,7 @@ describe('HookRunner', () => {
hookSpecificOutput: {
additionalContext: 'Context from hook 1',
},
format: 'json',
};
let hookCallCount = 0;
@@ -803,6 +808,7 @@ describe('HookRunner', () => {
expect(result.success).toBe(true);
expect(result.exitCode).toBe(0);
// Should convert plain text to structured output
expect(result.outputFormat).toBe('text');
expect(result.output).toEqual({
decision: 'allow',
systemMessage: invalidJson,
@@ -835,6 +841,7 @@ describe('HookRunner', () => {
);
expect(result.success).toBe(true);
expect(result.outputFormat).toBe('text');
expect(result.output).toEqual({
decision: 'allow',
systemMessage: malformedJson,
@@ -868,6 +875,7 @@ describe('HookRunner', () => {
expect(result.success).toBe(false);
expect(result.exitCode).toBe(1);
expect(result.outputFormat).toBe('text');
expect(result.output).toEqual({
decision: 'allow',
systemMessage: `Warning: ${invalidJson}`,
@@ -901,6 +909,7 @@ describe('HookRunner', () => {
expect(result.success).toBe(false);
expect(result.exitCode).toBe(2);
expect(result.outputFormat).toBe('text');
expect(result.output).toEqual({
decision: 'deny',
reason: invalidJson,
@@ -936,7 +945,11 @@ describe('HookRunner', () => {
});
it('should handle double-encoded JSON string', async () => {
const mockOutput = { decision: 'allow', reason: 'All good' };
const mockOutput = {
decision: 'allow',
reason: 'All good',
format: 'json',
};
const doubleEncodedJson = JSON.stringify(JSON.stringify(mockOutput));
mockSpawn.mockStdoutOn.mockImplementation(
+5 -1
View File
@@ -447,6 +447,7 @@ export class HookRunner {
// Parse output
let output: HookOutput | undefined;
let outputFormat: 'json' | 'text' | undefined;
const textToParse = stdout.trim() || stderr.trim();
if (textToParse) {
@@ -460,6 +461,7 @@ export class HookRunner {
if (parsed && typeof parsed === 'object') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
output = parsed as HookOutput;
outputFormat = 'json';
}
} catch {
// Not JSON, convert plain text to structured output
@@ -467,6 +469,7 @@ export class HookRunner {
textToParse,
exitCode || EXIT_CODE_SUCCESS,
);
outputFormat = 'text';
}
}
@@ -475,6 +478,7 @@ export class HookRunner {
eventName,
success: exitCode === EXIT_CODE_SUCCESS,
output,
outputFormat,
stdout,
stderr,
exitCode: exitCode || EXIT_CODE_SUCCESS,
@@ -523,7 +527,7 @@ export class HookRunner {
exitCode: number,
): HookOutput {
if (exitCode === EXIT_CODE_SUCCESS) {
// Success - treat as system message or additional context
// Success
return {
decision: 'allow',
systemMessage: text,
+3
View File
@@ -48,6 +48,8 @@ export interface BeforeModelHookResult {
reason?: string;
/** Synthetic response to return instead of calling the model (if blocked) */
syntheticResponse?: GenerateContentResponse;
/** Modified model override (if not blocked) */
modifiedModel?: string;
/** Modified config (if not blocked) */
modifiedConfig?: GenerateContentConfig;
/** Modified contents (if not blocked) */
@@ -292,6 +294,7 @@ export class HookSystem {
beforeModelOutput.applyLLMRequestModifications(llmRequest);
return {
blocked: false,
modifiedModel: modifiedRequest?.model,
modifiedConfig: modifiedRequest?.config,
modifiedContents: modifiedRequest?.contents,
};
+2
View File
@@ -734,6 +734,8 @@ export interface HookExecutionResult {
exitCode?: number;
duration: number;
error?: Error;
/** The format of the output provided by the hook */
outputFormat?: 'json' | 'text';
}
/**
+1 -2
View File
@@ -276,8 +276,7 @@ export * from './voice/responseFormatter.js';
// Export types from @google/genai
export type { Content, Part, FunctionCall } from '@google/genai';
// Export context types and profiles
export * from './context/types.js';
export * from './context/profiles.js';
export * from './core/errors.js';
@@ -61,7 +61,6 @@ describe('PromptProvider', () => {
topicState: new TopicState(),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
getSandboxEnabled: vi.fn().mockReturnValue(false),
getPlansDir: vi.fn().mockReturnValue('/tmp/project-temp/plans'),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'),
getPlansDir: vi.fn().mockReturnValue('/tmp/project-temp/plans'),
+1 -1
View File
@@ -188,7 +188,7 @@ export class PromptProvider {
() => ({
interactive: interactiveMode,
planModeToolsList,
plansDir: context.config.getPlansDir(),
plansDir: context.config.storage.getPlansDir(),
approvedPlanPath: context.config.getApprovedPlanPath(),
}),
isPlanMode,
@@ -142,6 +142,12 @@ public class GeminiSandbox {
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Auto)]
static extern bool ConvertStringSidToSid(string StringSid, out IntPtr ptrSid);
[DllImport("advapi32.dll", SetLastError = true)]
static extern bool InitializeAcl(IntPtr pAcl, uint nAclLength, uint dwAclRevision);
[DllImport("advapi32.dll", SetLastError = true)]
static extern bool AddMandatoryAce(IntPtr pAcl, uint dwAceRevision, uint AceFlags, uint MandatoryPolicy, IntPtr pLabelSid);
[DllImport("advapi32.dll", SetLastError = true)]
static extern bool SetTokenInformation(IntPtr TokenHandle, int TokenInformationClass, IntPtr TokenInformation, uint TokenInformationLength);
@@ -156,33 +162,99 @@ public class GeminiSandbox {
public SID_AND_ATTRIBUTES Label;
}
[DllImport("advapi32.dll", CharSet = CharSet.Auto, SetLastError = true)]
static extern bool ConvertStringSecurityDescriptorToSecurityDescriptor(string StringSecurityDescriptor, uint StringSDRevision, out IntPtr SecurityDescriptor, out uint SecurityDescriptorSize);
[DllImport("advapi32.dll", SetLastError = true)]
static extern bool GetSecurityDescriptorSacl(IntPtr pSecurityDescriptor, out bool lpbSaclPresent, out IntPtr pSacl, out bool lpbSaclDefaulted);
[DllImport("advapi32.dll", CharSet = CharSet.Auto, SetLastError = true)]
static extern uint SetNamedSecurityInfo(string pObjectName, int ObjectType, uint SecurityInfo, IntPtr psidOwner, IntPtr psidGroup, IntPtr pDacl, IntPtr pSacl);
[DllImport("kernel32.dll")]
static extern IntPtr LocalFree(IntPtr hMem);
[StructLayout(LayoutKind.Sequential, Pack = 1)]
struct TOKEN_PRIVILEGES {
public uint PrivilegeCount;
public LUID_AND_ATTRIBUTES Privileges;
}
[StructLayout(LayoutKind.Sequential)]
struct LUID_AND_ATTRIBUTES {
public LUID Luid;
public uint Attributes;
}
[StructLayout(LayoutKind.Sequential)]
struct LUID {
public uint LowPart;
public int HighPart;
}
[DllImport("advapi32.dll", SetLastError = true)]
static extern bool AdjustTokenPrivileges(IntPtr TokenHandle, bool DisableAllPrivileges, ref TOKEN_PRIVILEGES NewState, uint BufferLength, IntPtr PreviousState, IntPtr ReturnLength);
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Auto)]
static extern bool LookupPrivilegeValue(string lpSystemName, string lpName, out LUID lpLuid);
private const string SE_SECURITY_NAME = "SeSecurityPrivilege";
private const uint SE_PRIVILEGE_ENABLED = 0x00000002;
private const uint TOKEN_ADJUST_PRIVILEGES = 0x0020;
private const uint TOKEN_QUERY = 0x0008;
private static void EnablePrivilege(string privilege) {
IntPtr hToken;
if (OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, out hToken)) {
try {
LUID luid;
if (LookupPrivilegeValue(null, privilege, out luid)) {
TOKEN_PRIVILEGES tp = new TOKEN_PRIVILEGES();
tp.PrivilegeCount = 1;
tp.Privileges.Luid = luid;
tp.Privileges.Attributes = SE_PRIVILEGE_ENABLED;
AdjustTokenPrivileges(hToken, false, ref tp, 0, IntPtr.Zero, IntPtr.Zero);
}
} finally {
CloseHandle(hToken);
}
}
}
private const int TokenIntegrityLevel = 25;
private const uint SE_GROUP_INTEGRITY = 0x00000020;
private const uint TOKEN_ALL_ACCESS = 0xF01FF;
private const uint DISABLE_MAX_PRIVILEGE = 0x1;
private const int SE_FILE_OBJECT = 1;
private const uint LABEL_SECURITY_INFORMATION = 0x00000010;
private const uint SECURITY_MANDATORY_LOW_RID = 0x00001000;
private const uint SYSTEM_MANDATORY_LABEL_NO_WRITE_UP = 0x1;
private const uint CONTAINER_INHERIT_ACE = 0x2;
private const uint OBJECT_INHERIT_ACE = 0x1;
private static HashSet<string> forbiddenPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
static int Main(string[] args) {
if (args.Length < 3) {
Console.Error.WriteLine("Usage: GeminiSandbox.exe <network:0|1> <cwd> [--forbidden-manifest <path>] <command> [args...]");
Console.Error.WriteLine("Internal commands: __read <path>, __write <path>");
Console.Error.WriteLine("Usage: GeminiSandbox.exe <network:0|1> <cwd> [--setup-manifest <path>] <command> [args...]");
Console.Error.WriteLine("Internal commands: __read <path>, __write <path>, __apply_batch_acls <manifestPath>");
return 1;
}
bool networkAccess = args[0] == "1";
string cwd = args[1];
HashSet<string> forbiddenPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
int argIndex = 2;
int argIndex = 0;
string networkArg = args[argIndex++];
bool networkAccess = networkArg == "1";
string cwd = args[argIndex++];
if (argIndex < args.Length && args[argIndex] == "--forbidden-manifest") {
// Batch ACL command: __apply_batch_acls <manifestPath> (maintained for backward compatibility/standalone use)
if (networkArg == "__apply_batch_acls") {
ApplyManifest(cwd); // In this case, cwd is actually the manifest path
return 0;
}
if (argIndex < args.Length && args[argIndex] == "--setup-manifest") {
if (argIndex + 1 < args.Length) {
string manifestPath = args[argIndex + 1];
if (File.Exists(manifestPath)) {
foreach (string line in File.ReadAllLines(manifestPath)) {
if (!string.IsNullOrWhiteSpace(line)) {
forbiddenPaths.Add(GetNormalizedPath(line.Trim()));
}
}
}
ApplyManifest(args[argIndex + 1]);
argIndex += 2;
}
}
@@ -278,7 +350,7 @@ public class GeminiSandbox {
return 1;
}
string path = args[argIndex + 1];
CheckForbidden(path, forbiddenPaths);
CheckForbidden(path, cwd);
return RunInImpersonation(hRestrictedToken, () => {
try {
using (FileStream fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read))
@@ -297,7 +369,7 @@ public class GeminiSandbox {
return 1;
}
string path = args[argIndex + 1];
CheckForbidden(path, forbiddenPaths);
CheckForbidden(path, cwd);
try {
using (MemoryStream ms = new MemoryStream()) {
@@ -321,6 +393,22 @@ public class GeminiSandbox {
}
// External Process
string commandLine = "";
for (int i = argIndex; i < args.Length; i++) {
if (i > argIndex) commandLine += " ";
commandLine += QuoteArgument(args[i]);
}
// Third layer of defense: block the external process if it's trying to touch a forbidden path directly
foreach (string arg in args) {
try {
CheckForbidden(arg, cwd);
} catch (Exception e) {
Console.Error.WriteLine(e.Message);
return 1;
}
}
STARTUPINFO si = new STARTUPINFO();
si.cb = (uint)Marshal.SizeOf(si);
si.dwFlags = 0x00000100; // STARTF_USESTDHANDLES
@@ -328,12 +416,6 @@ public class GeminiSandbox {
si.hStdOutput = GetStdHandle(-11);
si.hStdError = GetStdHandle(-12);
string commandLine = "";
for (int i = argIndex; i < args.Length; i++) {
if (i > argIndex) commandLine += " ";
commandLine += QuoteArgument(args[i]);
}
// Creation Flags: 0x01000000 (CREATE_BREAKAWAY_FROM_JOB) to allow job assignment if parent is in job
// 0x00000004 (CREATE_SUSPENDED) to prevent the process from executing before being placed in the job
uint creationFlags = 0x01000000 | 0x00000004;
@@ -356,7 +438,7 @@ public class GeminiSandbox {
int err = Marshal.GetLastWin32Error();
Console.Error.WriteLine("Error: WaitForSingleObject failed (" + err + ")");
}
uint exitCode = 0;
if (!GetExitCodeProcess(pi.hProcess, out exitCode)) {
int err = Marshal.GetLastWin32Error();
@@ -395,21 +477,97 @@ public class GeminiSandbox {
}
}
private static string GetNormalizedPath(string path) {
string fullPath = Path.GetFullPath(path);
StringBuilder longPath = new StringBuilder(1024);
uint result = GetLongPathName(fullPath, longPath, (uint)longPath.Capacity);
if (result > 0 && result < longPath.Capacity) {
return longPath.ToString();
private static void SetLowIntegritySacl(string path) {
IntPtr pSid = IntPtr.Zero;
IntPtr pSacl = IntPtr.Zero;
try {
if (ConvertStringSidToSid("S-1-16-4096", out pSid)) {
uint cbAcl = 100;
pSacl = Marshal.AllocHGlobal((int)cbAcl);
if (InitializeAcl(pSacl, cbAcl, 2)) {
if (AddMandatoryAce(pSacl, 2, CONTAINER_INHERIT_ACE | OBJECT_INHERIT_ACE, SYSTEM_MANDATORY_LABEL_NO_WRITE_UP, pSid)) {
SetNamedSecurityInfo(path, 1, 16, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, pSacl);
}
}
}
} catch {
// Ignore errors for individual paths
} finally {
if (pSid != IntPtr.Zero) LocalFree(pSid);
if (pSacl != IntPtr.Zero) Marshal.FreeHGlobal(pSacl);
}
return fullPath;
}
private static void CheckForbidden(string path, HashSet<string> forbiddenPaths) {
string fullPath = GetNormalizedPath(path);
private static void ApplyManifest(string manifestPath) {
EnablePrivilege(SE_SECURITY_NAME);
if (!File.Exists(manifestPath)) return;
foreach (string rawLine in File.ReadAllLines(manifestPath)) {
string line = rawLine.Trim();
if (string.IsNullOrEmpty(line) || line.Length < 3) continue;
// Handle UTF-8 BOM if present on the first line
if ((int)line[0] == 65279) line = line.Substring(1).Trim();
if (line.Length < 3) continue;
char op = line[0];
string path = line.Substring(1).Trim();
if (op == 'L') {
SetLowIntegritySacl(path);
try {
if (Directory.Exists(path)) {
DirectoryInfo dInfo = new DirectoryInfo(path);
DirectorySecurity ds = dInfo.GetAccessControl();
ds.AddAccessRule(new FileSystemAccessRule(new SecurityIdentifier("S-1-16-4096"), FileSystemRights.Modify, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow));
dInfo.SetAccessControl(ds);
} else if (File.Exists(path)) {
FileInfo fInfo = new FileInfo(path);
FileSecurity fs = fInfo.GetAccessControl();
fs.AddAccessRule(new FileSystemAccessRule(new SecurityIdentifier("S-1-16-4096"), FileSystemRights.Modify, AccessControlType.Allow));
fInfo.SetAccessControl(fs);
}
} catch {
// Ignore access errors
}
} else if (op == 'D') {
DenyLowIntegrityDacl(path);
forbiddenPaths.Add(GetNormalizedPath(path, null));
}
}
}
private static string GetNormalizedPath(string path, string basePath) {
try {
string absolutePath = path;
if (!Path.IsPathRooted(path) && !string.IsNullOrEmpty(basePath)) {
absolutePath = Path.Combine(basePath, path);
}
string fullPath = Path.GetFullPath(absolutePath);
StringBuilder longPath = new StringBuilder(1024);
uint result = GetLongPathName(fullPath, longPath, (uint)longPath.Capacity);
if (result > 0 && result < longPath.Capacity) {
return longPath.ToString();
}
return fullPath;
} catch {
return path;
}
}
private static void CheckForbidden(string arg, string basePath) {
foreach (string forbidden in forbiddenPaths) {
if (fullPath.Equals(forbidden, StringComparison.OrdinalIgnoreCase) || fullPath.StartsWith(forbidden + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) {
throw new UnauthorizedAccessException("Access to forbidden path is denied: " + path);
if (arg.IndexOf(forbidden, StringComparison.OrdinalIgnoreCase) >= 0) {
throw new UnauthorizedAccessException("Access to forbidden path is denied (matched " + forbidden + "): " + arg);
}
}
// Also check normalized path for direct hits
string fullPath = GetNormalizedPath(arg, basePath);
foreach (string forbidden in forbiddenPaths) {
if (fullPath.Equals(forbidden, StringComparison.OrdinalIgnoreCase) ||
fullPath.StartsWith(forbidden + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) {
throw new UnauthorizedAccessException("Access to forbidden path is denied: " + arg);
}
}
}
@@ -456,4 +614,23 @@ public class GeminiSandbox {
sb.Append('\"');
return sb.ToString();
}
private static void DenyLowIntegrityDacl(string path) {
try {
if (Directory.Exists(path)) {
DirectorySecurity ds = Directory.GetAccessControl(path);
ds.SetAccessRuleProtection(true, true);
ds.AddAccessRule(new FileSystemAccessRule(new SecurityIdentifier("S-1-16-4096"), FileSystemRights.FullControl, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Deny));
Directory.SetAccessControl(path, ds);
} else if (File.Exists(path)) {
FileSecurity fs = File.GetAccessControl(path);
fs.SetAccessRuleProtection(true, true);
fs.AddAccessRule(new FileSystemAccessRule(new SecurityIdentifier("S-1-16-4096"), FileSystemRights.FullControl, AccessControlType.Deny));
File.SetAccessControl(path, fs);
}
} catch (Exception e) {
Console.Error.WriteLine("Error in DenyLowIntegrityDacl for " + path + ": " + e.Message);
}
}
}
@@ -11,22 +11,24 @@ import path from 'node:path';
import { WindowsSandboxManager } from './WindowsSandboxManager.js';
import * as sandboxManager from '../../services/sandboxManager.js';
import type { SandboxRequest } from '../../services/sandboxManager.js';
import { spawnAsync } from '../../utils/shell-utils.js';
import type { SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js';
import { spawnAsync } from '../../utils/shell-utils.js';
vi.mock('../../utils/shell-utils.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../utils/shell-utils.js')>();
return {
...actual,
spawnAsync: vi.fn(),
initializeShellParsers: vi.fn(),
isStrictlyApproved: vi.fn().mockResolvedValue(true),
spawnAsync: vi
.fn()
.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }),
};
});
describe('WindowsSandboxManager', () => {
let manager: WindowsSandboxManager;
let manager: WindowsSandboxManager | undefined;
let testCwd: string;
/**
@@ -58,6 +60,7 @@ describe('WindowsSandboxManager', () => {
});
testCwd = createTempDir('cwd');
vi.spyOn(fs, 'writeFileSync');
manager = new WindowsSandboxManager({
workspace: testCwd,
@@ -74,6 +77,10 @@ describe('WindowsSandboxManager', () => {
});
it('should prepare a GeminiSandbox.exe command', async () => {
manager = new WindowsSandboxManager({
workspace: testCwd,
forbiddenPaths: async () => [],
});
const req: SandboxRequest = {
command: 'whoami',
args: ['/groups'],
@@ -90,14 +97,18 @@ describe('WindowsSandboxManager', () => {
expect(result.args).toEqual([
'0',
testCwd,
'--forbidden-manifest',
expect.stringMatching(/manifest\.txt$/),
'--setup-manifest',
expect.stringMatching(/gemini-cli-sandbox-.*\.txt$/),
'whoami',
'/groups',
]);
});
it('should handle networkAccess from config', async () => {
manager = new WindowsSandboxManager({
workspace: testCwd,
forbiddenPaths: async () => [],
});
const req: SandboxRequest = {
command: 'whoami',
args: [],
@@ -128,20 +139,18 @@ describe('WindowsSandboxManager', () => {
await manager.prepareCommand(req);
// Verify spawnAsync was called for icacls
// Verify spawnAsync was NOT called for icacls (batching manifest is used instead)
const icaclsCalls = vi
.mocked(spawnAsync)
.mock.calls.filter((call) => call[0] === 'icacls');
// Should NOT have called icacls for C:\, D:\, etc.
const driveRootCalls = icaclsCalls.filter(
(call) =>
typeof call[1]?.[0] === 'string' && /^[A-Z]:\\$/.test(call[1][0]),
);
expect(driveRootCalls).toHaveLength(0);
expect(icaclsCalls).toHaveLength(0);
});
it('should handle network access from additionalPermissions', async () => {
manager = new WindowsSandboxManager({
workspace: testCwd,
forbiddenPaths: async () => [],
});
const req: SandboxRequest = {
command: 'whoami',
args: [],
@@ -159,11 +168,12 @@ describe('WindowsSandboxManager', () => {
});
it('should reject network access in Plan mode', async () => {
const planManager = new WindowsSandboxManager({
manager = new WindowsSandboxManager({
workspace: testCwd,
modeConfig: { readonly: true, allowOverrides: false },
forbiddenPaths: async () => [],
});
const req: SandboxRequest = {
command: 'curl',
args: ['google.com'],
@@ -174,7 +184,7 @@ describe('WindowsSandboxManager', () => {
},
};
await expect(planManager.prepareCommand(req)).rejects.toThrow(
await expect(manager.prepareCommand(req)).rejects.toThrow(
'Sandbox request rejected: Cannot override readonly/network/filesystem restrictions in Plan mode.',
);
});
@@ -189,7 +199,7 @@ describe('WindowsSandboxManager', () => {
}),
} as unknown as SandboxPolicyManager;
const managerWithPolicy = new WindowsSandboxManager({
manager = new WindowsSandboxManager({
workspace: testCwd,
modeConfig: { allowOverrides: true, network: false },
policyManager: mockPolicyManager,
@@ -203,24 +213,22 @@ describe('WindowsSandboxManager', () => {
env: {},
};
const result = await managerWithPolicy.prepareCommand(req);
const result = await manager.prepareCommand(req);
expect(result.args[0]).toBe('1'); // Network allowed by persistent policy
const icaclsArgs = vi
.mocked(spawnAsync)
.mock.calls.filter((c) => c[0] === 'icacls')
.map((c) => c[1]);
expect(icaclsArgs).toContainEqual([
persistentPath,
'/grant',
'*S-1-16-4096:(OI)(CI)(M)',
'/setintegritylevel',
'(OI)(CI)Low',
]);
const writeFileSyncCalls = vi.mocked(fs.writeFileSync).mock.calls;
const aclCall = writeFileSyncCalls.find((call) =>
String(call[0]).match(/gemini-cli-sandbox-.*\.txt$/),
);
expect(aclCall).toBeDefined();
expect(aclCall![1]).toContain(`L ${persistentPath}`);
});
it('should sanitize environment variables', async () => {
manager = new WindowsSandboxManager({
workspace: testCwd,
forbiddenPaths: async () => [],
});
const req: SandboxRequest = {
command: 'test',
args: [],
@@ -244,6 +252,10 @@ describe('WindowsSandboxManager', () => {
});
it('should ensure governance files exist', async () => {
manager = new WindowsSandboxManager({
workspace: testCwd,
forbiddenPaths: async () => [],
});
const req: SandboxRequest = {
command: 'test',
args: [],
@@ -272,28 +284,15 @@ describe('WindowsSandboxManager', () => {
},
};
await manager.prepareCommand(req);
await manager!.prepareCommand(req);
const icaclsArgs = vi
.mocked(spawnAsync)
.mock.calls.filter((c) => c[0] === 'icacls')
.map((c) => c[1]);
expect(icaclsArgs).toContainEqual([
testCwd,
'/grant',
'*S-1-16-4096:(OI)(CI)(M)',
'/setintegritylevel',
'(OI)(CI)Low',
]);
expect(icaclsArgs).toContainEqual([
allowedPath,
'/grant',
'*S-1-16-4096:(OI)(CI)(M)',
'/setintegritylevel',
'(OI)(CI)Low',
]);
const writeFileSyncCalls = vi.mocked(fs.writeFileSync).mock.calls;
const aclCall = writeFileSyncCalls.find((call) =>
String(call[0]).match(/gemini-cli-sandbox-.*\.txt$/),
);
expect(aclCall).toBeDefined();
expect(aclCall![1]).toContain(`L ${path.resolve(testCwd)}`);
expect(aclCall![1]).toContain(`L ${path.resolve(allowedPath)}`);
} finally {
fs.rmSync(allowedPath, { recursive: true, force: true });
}
@@ -316,28 +315,26 @@ describe('WindowsSandboxManager', () => {
},
};
await manager.prepareCommand(req);
await manager!.prepareCommand(req);
const icaclsArgs = vi
.mocked(spawnAsync)
.mock.calls.filter((c) => c[0] === 'icacls')
.map((c) => c[1]);
expect(icaclsArgs).toContainEqual([
extraWritePath,
'/grant',
'*S-1-16-4096:(OI)(CI)(M)',
'/setintegritylevel',
'(OI)(CI)Low',
]);
const writeFileSyncCalls = vi.mocked(fs.writeFileSync).mock.calls;
const aclCall = writeFileSyncCalls.find((call) =>
String(call[0]).match(/gemini-cli-sandbox-.*\.txt$/),
);
expect(aclCall).toBeDefined();
expect(aclCall![1]).toContain(`L ${path.resolve(extraWritePath)}`);
} finally {
fs.rmSync(extraWritePath, { recursive: true, force: true });
}
});
it.runIf(process.platform === 'win32')(
'should reject UNC paths in grantLowIntegrityAccess',
'should reject UNC paths in grantLowIntegrityOp',
async () => {
manager = new WindowsSandboxManager({
workspace: testCwd,
forbiddenPaths: async () => [],
});
const uncPath = '\\\\attacker\\share\\malicious.txt';
const req: SandboxRequest = {
command: 'test',
@@ -356,12 +353,13 @@ describe('WindowsSandboxManager', () => {
// Rejected because it's an unreachable/invalid UNC path or it doesn't exist
await expect(manager.prepareCommand(req)).rejects.toThrow();
const icaclsArgs = vi
.mocked(spawnAsync)
.mock.calls.filter((c) => c[0] === 'icacls')
.map((c) => c[1]);
expect(icaclsArgs).not.toContainEqual(expect.arrayContaining([uncPath]));
const writeFileSyncCalls = vi.mocked(fs.writeFileSync).mock.calls;
const aclCall = writeFileSyncCalls.find((call) =>
String(call[0]).match(/gemini-cli-sandbox-.*\.txt$/),
);
if (aclCall) {
expect(aclCall[1]).not.toContain(`L ${uncPath}`);
}
},
);
@@ -388,31 +386,19 @@ describe('WindowsSandboxManager', () => {
},
};
await manager.prepareCommand(req);
await manager!.prepareCommand(req);
const icaclsArgs = vi
.mocked(spawnAsync)
.mock.calls.filter((c) => c[0] === 'icacls')
.map((c) => c[1]);
expect(icaclsArgs).toContainEqual([
path.resolve(longPath),
'/grant',
'*S-1-16-4096:(OI)(CI)(M)',
'/setintegritylevel',
'(OI)(CI)Low',
]);
expect(icaclsArgs).toContainEqual([
path.resolve(devicePath),
'/grant',
'*S-1-16-4096:(OI)(CI)(M)',
'/setintegritylevel',
'(OI)(CI)Low',
]);
const writeFileSyncCalls = vi.mocked(fs.writeFileSync).mock.calls;
const aclCall = writeFileSyncCalls.find((call) =>
String(call[0]).match(/gemini-cli-sandbox-.*\.txt$/),
);
expect(aclCall).toBeDefined();
expect(aclCall![1]).toContain(`L ${path.resolve(longPath)}`);
expect(aclCall![1]).toContain(`L ${path.resolve(devicePath)}`);
},
);
it('skips denying access to non-existent forbidden paths to prevent icacls failure', async () => {
it('skips denying access to non-existent forbidden paths to prevent failure', async () => {
const missingPath = path.join(
os.tmpdir(),
'gemini-cli-test-missing',
@@ -424,7 +410,7 @@ describe('WindowsSandboxManager', () => {
fs.rmSync(missingPath, { recursive: true, force: true });
}
const managerWithForbidden = new WindowsSandboxManager({
manager = new WindowsSandboxManager({
workspace: testCwd,
forbiddenPaths: async () => [missingPath],
});
@@ -436,20 +422,70 @@ describe('WindowsSandboxManager', () => {
env: {},
};
await managerWithForbidden.prepareCommand(req);
await manager.prepareCommand(req);
// Should NOT have called icacls to deny the missing path
expect(spawnAsync).not.toHaveBeenCalledWith('icacls', [
path.resolve(missingPath),
'/deny',
'*S-1-16-4096:(OI)(CI)(F)',
]);
// Should NOT have included the missing path in the ACL manifest
const writeFileSyncCalls = vi.mocked(fs.writeFileSync).mock.calls;
const aclCall = writeFileSyncCalls.find((call) =>
String(call[0]).match(/gemini-cli-sandbox-.*\.txt$/),
);
if (aclCall) {
expect(aclCall[1]).not.toContain(`D ${path.resolve(missingPath)}`);
}
});
it('should deny access to discovered secret files (e.g., .env)', async () => {
manager = new WindowsSandboxManager({
workspace: testCwd,
forbiddenPaths: async () => [],
});
const envFile = path.join(testCwd, '.env');
fs.writeFileSync(envFile, 'API_KEY=secret');
const req: SandboxRequest = {
command: 'test',
args: [],
cwd: testCwd,
env: {},
};
await manager.prepareCommand(req);
const writeFileSyncCalls = vi.mocked(fs.writeFileSync).mock.calls;
const aclCall = writeFileSyncCalls.find((call) =>
String(call[0]).match(/gemini-cli-sandbox-.*\.txt$/),
);
expect(aclCall).toBeDefined();
expect(aclCall![1]).toContain(`D ${path.resolve(envFile)}`);
});
describe('isKnownSafeCommand', () => {
it('should return true for approved tools in modeConfig', () => {
manager = new WindowsSandboxManager({
workspace: testCwd,
modeConfig: { approvedTools: ['my-safe-tool'] },
forbiddenPaths: async () => [],
});
expect(manager.isKnownSafeCommand(['my-safe-tool', 'arg'])).toBe(true);
expect(manager.isKnownSafeCommand(['MY-SAFE-TOOL', 'arg'])).toBe(true);
});
it('should fall back to default isKnownSafeCommand logic', () => {
manager = new WindowsSandboxManager({
workspace: testCwd,
forbiddenPaths: async () => [],
});
// 'git' is typically a known safe command in commandSafety.ts
expect(manager.isKnownSafeCommand(['git', 'status'])).toBe(true);
expect(manager.isKnownSafeCommand(['unknown-tool'])).toBe(false);
});
});
it('should deny Low Integrity access to forbidden paths', async () => {
const forbiddenPath = createTempDir('forbidden');
try {
const managerWithForbidden = new WindowsSandboxManager({
manager = new WindowsSandboxManager({
workspace: testCwd,
forbiddenPaths: async () => [forbiddenPath],
});
@@ -461,13 +497,14 @@ describe('WindowsSandboxManager', () => {
env: {},
};
await managerWithForbidden.prepareCommand(req);
await manager.prepareCommand(req);
expect(spawnAsync).toHaveBeenCalledWith('icacls', [
forbiddenPath,
'/deny',
'*S-1-16-4096:(OI)(CI)(F)',
]);
const writeFileSyncCalls = vi.mocked(fs.writeFileSync).mock.calls;
const aclCall = writeFileSyncCalls.find((call) =>
String(call[0]).match(/gemini-cli-sandbox-.*\.txt$/),
);
expect(aclCall).toBeDefined();
expect(aclCall![1]).toContain(`D ${path.resolve(forbiddenPath)}`);
} finally {
fs.rmSync(forbiddenPath, { recursive: true, force: true });
}
@@ -476,7 +513,7 @@ describe('WindowsSandboxManager', () => {
it('should override allowed paths if a path is also in forbidden paths', async () => {
const conflictPath = createTempDir('conflict');
try {
const managerWithForbidden = new WindowsSandboxManager({
manager = new WindowsSandboxManager({
workspace: testCwd,
forbiddenPaths: async () => [conflictPath],
});
@@ -491,27 +528,21 @@ describe('WindowsSandboxManager', () => {
},
};
await managerWithForbidden.prepareCommand(req);
await manager.prepareCommand(req);
const spawnMock = vi.mocked(spawnAsync);
const allowCallIndex = spawnMock.mock.calls.findIndex(
(call) =>
call[1] &&
call[1].includes('/setintegritylevel') &&
call[0] === 'icacls' &&
call[1][0] === conflictPath,
);
const denyCallIndex = spawnMock.mock.calls.findIndex(
(call) =>
call[1] &&
call[1].includes('/deny') &&
call[0] === 'icacls' &&
call[1][0] === conflictPath,
const writeFileSyncCalls = vi.mocked(fs.writeFileSync).mock.calls;
const aclCall = writeFileSyncCalls.find((call) =>
String(call[0]).match(/gemini-cli-sandbox-.*\.txt$/),
);
expect(aclCall).toBeDefined();
// Conflict should have been filtered out of allow calls
expect(allowCallIndex).toBe(-1);
expect(denyCallIndex).toBeGreaterThan(-1);
const content = String(aclCall![1]);
const allowIndex = content.indexOf(`L ${path.resolve(conflictPath)}`);
const denyIndex = content.indexOf(`D ${path.resolve(conflictPath)}`);
// Forbidden path should have been filtered out of grant list
expect(allowIndex).toBe(-1);
expect(denyIndex).toBeGreaterThan(-1);
} finally {
fs.rmSync(conflictPath, { recursive: true, force: true });
}
@@ -527,14 +558,14 @@ describe('WindowsSandboxManager', () => {
env: {},
};
const result = await manager.prepareCommand(req);
const result = await manager!.prepareCommand(req);
// [network, cwd, --forbidden-manifest, manifestPath, command, ...args]
// [network, cwd, --setup-manifest, manifestPath, command, ...args]
expect(result.args[4]).toBe('__write');
expect(result.args[5]).toBe(filePath);
});
it('should safely handle special characters in __write path using environment variables', async () => {
it('should safely handle special characters in __write path', async () => {
const maliciousPath = path.join(testCwd, 'foo & echo bar; ! .txt');
fs.writeFileSync(maliciousPath, '');
const req: SandboxRequest = {
@@ -544,7 +575,7 @@ describe('WindowsSandboxManager', () => {
env: {},
};
const result = await manager.prepareCommand(req);
const result = await manager!.prepareCommand(req);
// Native commands pass arguments directly; the binary handles quoting via QuoteArgument
expect(result.args[4]).toBe('__write');
@@ -561,7 +592,7 @@ describe('WindowsSandboxManager', () => {
env: {},
};
const result = await manager.prepareCommand(req);
const result = await manager!.prepareCommand(req);
expect(result.args[4]).toBe('__read');
expect(result.args[5]).toBe(filePath);
@@ -575,14 +606,12 @@ describe('WindowsSandboxManager', () => {
env: {},
};
const result = await manager.prepareCommand(req);
const result = await manager!.prepareCommand(req);
const manifestPath = result.args[3];
expect(fs.existsSync(manifestPath)).toBe(true);
expect(result.cleanup).toBeDefined();
result.cleanup?.();
expect(fs.existsSync(manifestPath)).toBe(false);
expect(fs.existsSync(path.dirname(manifestPath))).toBe(false);
});
});
@@ -41,7 +41,6 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// S-1-16-4096 is the SID for "Low Mandatory Level" (Low Integrity)
const LOW_INTEGRITY_SID = '*S-1-16-4096';
/**
* A SandboxManager implementation for Windows that uses Restricted Tokens,
@@ -50,8 +49,15 @@ const LOW_INTEGRITY_SID = '*S-1-16-4096';
*/
export class WindowsSandboxManager implements SandboxManager {
static readonly HELPER_EXE = 'GeminiSandbox.exe';
private readonly helperPath: string;
static readonly HELPER_SOURCE = 'GeminiSandbox.cs';
private helperPath: string;
private initialized = false;
/**
* Optimistically caches modified ACLs to prevent redundant Win32 API calls.
* Skips even if a previous application failed.
*/
private readonly allowedCache = new Set<string>();
private readonly deniedCache = new Set<string>();
@@ -72,119 +78,51 @@ export class WindowsSandboxManager implements SandboxManager {
return isDangerousCommand(args);
}
parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined {
return parseWindowsSandboxDenials(result);
}
getWorkspace(): string {
return this.options.workspace;
}
getOptions(): GlobalSandboxOptions {
return this.options;
}
/**
* Ensures a file or directory exists.
*/
private touch(filePath: string, isDirectory: boolean): void {
try {
// If it exists (even as a broken symlink), do nothing
if (fs.lstatSync(filePath)) return;
} catch {
// Ignore ENOENT
}
if (isDirectory) {
fs.mkdirSync(filePath, { recursive: true });
} else {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.closeSync(fs.openSync(filePath, 'a'));
}
}
private async ensureInitialized(): Promise<void> {
if (this.initialized) return;
if (os.platform() !== 'win32') {
this.initialized = true;
return;
}
try {
if (!fs.existsSync(this.helperPath)) {
debugLogger.log(
`WindowsSandboxManager: Helper not found at ${this.helperPath}. Attempting to compile...`,
'WindowsSandboxManager: Helper not found at',
this.helperPath,
);
const sourcePath = path.resolve(
__dirname,
WindowsSandboxManager.HELPER_SOURCE,
);
// If the exe doesn't exist, we try to compile it from the .cs file
const sourcePath = this.helperPath.replace(/\.exe$/, '.cs');
if (fs.existsSync(sourcePath)) {
const systemRoot = process.env['SystemRoot'] || 'C:\\Windows';
const cscPaths = [
'csc.exe', // Try in PATH first
path.join(
debugLogger.log(
'WindowsSandboxManager: Compiling helper from source...',
);
try {
// Try to compile using csc.exe (C# compiler, usually in Windows\Microsoft.NET\Framework64\v4.0.30319)
const systemRoot = process.env['SystemRoot'] || 'C:\\Windows';
const cscPath = path.join(
systemRoot,
'Microsoft.NET',
'Framework64',
'v4.0.30319',
'csc.exe',
),
path.join(
systemRoot,
'Microsoft.NET',
'Framework',
'v4.0.30319',
'csc.exe',
),
// Added newer framework paths
path.join(
systemRoot,
'Microsoft.NET',
'Framework64',
'v4.8',
'csc.exe',
),
path.join(
systemRoot,
'Microsoft.NET',
'Framework',
'v4.8',
'csc.exe',
),
path.join(
systemRoot,
'Microsoft.NET',
'Framework64',
'v3.5',
'csc.exe',
),
];
let compiled = false;
for (const csc of cscPaths) {
try {
);
if (fs.existsSync(cscPath)) {
await spawnAsync(cscPath, [
'/target:exe',
`/out:${this.helperPath}`,
sourcePath,
]);
debugLogger.log(
`WindowsSandboxManager: Trying to compile using ${csc}...`,
`WindowsSandboxManager: Compiled helper to ${this.helperPath}`,
);
// We use spawnAsync but we don't need to capture output
await spawnAsync(csc, ['/out:' + this.helperPath, sourcePath]);
} else {
debugLogger.log(
`WindowsSandboxManager: Successfully compiled sandbox helper at ${this.helperPath}`,
);
compiled = true;
break;
} catch (e) {
debugLogger.log(
`WindowsSandboxManager: Failed to compile using ${csc}: ${e instanceof Error ? e.message : String(e)}`,
'WindowsSandboxManager: csc.exe not found. Cannot compile helper.',
);
}
}
if (!compiled) {
} catch (e) {
debugLogger.log(
'WindowsSandboxManager: Failed to compile sandbox helper from any known CSC path.',
'WindowsSandboxManager: Failed to compile helper:',
e,
);
}
} else {
@@ -274,7 +212,9 @@ export class WindowsSandboxManager implements SandboxManager {
// New files created within these roots will inherit the Low label.
const writableRoots: string[] = [];
// 1. Workspace access
// 1. Determine filesystem permissions to grant
const pathsToGrant = new Set<string>();
const isApproved = allowOverrides
? await isStrictlyApproved(
command,
@@ -284,43 +224,24 @@ export class WindowsSandboxManager implements SandboxManager {
: false;
if (!isReadonlyMode || isApproved) {
await this.grantLowIntegrityAccess(this.options.workspace);
pathsToGrant.add(this.options.workspace);
writableRoots.push(this.options.workspace);
}
// 2. Globally included directories
const includeDirs = sanitizePaths(this.options.includeDirectories);
for (const includeDir of includeDirs) {
await this.grantLowIntegrityAccess(includeDir);
writableRoots.push(includeDir);
}
// 3. Explicitly allowed paths from the request policy
for (const allowedPath of allowedPaths) {
const resolved = resolveToRealPath(allowedPath);
try {
await fs.promises.access(resolved, fs.constants.F_OK);
} catch {
throw new Error(
`Sandbox request rejected: Allowed path does not exist: ${resolved}. ` +
'On Windows, granular sandbox access can only be granted to existing paths to avoid broad parent directory permissions.',
);
}
await this.grantLowIntegrityAccess(resolved);
allowedPaths.forEach((p) => {
const resolved = resolveToRealPath(p);
pathsToGrant.add(resolved);
writableRoots.push(resolved);
}
});
// 4. Additional write paths (e.g. from internal __write command)
const additionalWritePaths = sanitizePaths(
mergedAdditional.fileSystem?.write,
);
for (const writePath of additionalWritePaths) {
const resolved = resolveToRealPath(writePath);
try {
await fs.promises.access(resolved, fs.constants.F_OK);
await this.grantLowIntegrityAccess(resolved);
continue;
} catch {
const extraWritePaths =
sanitizePaths(mergedAdditional.fileSystem?.write) || [];
extraWritePaths.forEach((p) => {
const resolved = resolveToRealPath(p);
if (fs.existsSync(resolved)) {
pathsToGrant.add(resolved);
writableRoots.push(resolved);
} else {
// If the file doesn't exist, it's only allowed if it resides within a granted root.
const isInherited = writableRoots.some((root) =>
isSubpath(root, resolved),
@@ -333,207 +254,150 @@ export class WindowsSandboxManager implements SandboxManager {
);
}
}
}
});
// 2. Collect secret files and apply protective ACLs
// On Windows, we explicitly deny access to secret files for Low Integrity
// processes to ensure they cannot be read or written.
const secretsToBlock: string[] = [];
const includeDirs = sanitizePaths(this.options.includeDirectories);
includeDirs.forEach((p) => {
const resolved = resolveToRealPath(p);
pathsToGrant.add(resolved);
writableRoots.push(resolved);
});
// 2. Identify forbidden paths and secrets to deny
const pathsToDeny = new Set<string>();
forbiddenPaths.forEach((p) => pathsToDeny.add(resolveToRealPath(p)));
// Scoped scan for secrets to explicitly block for Low Integrity processes
const searchDirs = new Set([
this.options.workspace,
...allowedPaths,
...includeDirs,
]);
for (const dir of searchDirs) {
try {
// We use maxDepth 3 to catch common nested secrets while keeping performance high.
const secretFiles = await findSecretFiles(dir, 3);
for (const secretFile of secretFiles) {
try {
secretsToBlock.push(secretFile);
await this.denyLowIntegrityAccess(secretFile);
} catch (e) {
debugLogger.log(
`WindowsSandboxManager: Failed to secure secret file ${secretFile}`,
e,
);
}
await Promise.all(
Array.from(searchDirs).map(async (dir) => {
try {
// We use maxDepth 3 to catch common nested secrets while keeping performance high.
const secrets = await findSecretFiles(dir, 3);
secrets.forEach((s) => pathsToDeny.add(resolveToRealPath(s)));
} catch (e) {
debugLogger.log(
`WindowsSandboxManager: Secret scan failed for ${dir}`,
e,
);
}
} catch (e) {
debugLogger.log(
`WindowsSandboxManager: Failed to find secret files in ${dir}`,
e,
);
}
}
}),
);
// 3. Reconcile Grant and Deny paths
for (const p of pathsToGrant) {
if (pathsToDeny.has(p)) {
pathsToGrant.delete(p);
continue;
}
// Denies access to forbiddenPaths for Low Integrity processes.
// Note: Denying access to arbitrary paths (like system files) via icacls
// is restricted to avoid host corruption. External commands rely on
// Low Integrity read/write restrictions, while internal commands
// use the manifest for enforcement.
for (const forbiddenPath of forbiddenPaths) {
try {
await this.denyLowIntegrityAccess(forbiddenPath);
} catch (e) {
debugLogger.log(
`WindowsSandboxManager: Failed to secure forbidden path ${forbiddenPath}`,
e,
);
await fs.promises.access(p, fs.constants.F_OK);
} catch {
// If it doesn't exist, we can't grant access on Windows.
pathsToGrant.delete(p);
}
}
// 3. Protected governance files
// These must exist on the host before running the sandbox to prevent
// the sandboxed process from creating them with Low integrity.
// By being created as Medium integrity, they are write-protected from Low processes.
// 4. Generate setup manifest operations (L = Grant, D = Deny)
const opResults = await Promise.all([
...Array.from(pathsToGrant).map((p) => this.getLowIntegrityOp(p, 'L')),
...Array.from(pathsToDeny).map((p) => this.getLowIntegrityOp(p, 'D')),
]);
const pendingAcls = opResults.filter(
(op): op is string => op !== undefined,
);
// 5. Ensure governance files are write-protected
for (const file of GOVERNANCE_FILES) {
const filePath = path.join(this.options.workspace, file.path);
this.touch(filePath, file.isDirectory);
this.touch(
path.join(this.options.workspace, file.path),
file.isDirectory,
);
}
// 4. Forbidden paths manifest
// We use a manifest file to avoid command-line length limits.
const allForbidden = Array.from(
new Set([...secretsToBlock, ...forbiddenPaths]),
);
const tempDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-forbidden-'),
);
const manifestPath = path.join(tempDir, 'manifest.txt');
fs.writeFileSync(manifestPath, allForbidden.join('\n'));
// 5. Construct the helper command
// GeminiSandbox.exe <network:0|1> <cwd> --forbidden-manifest <path> <command> [args...]
const program = this.helperPath;
const finalArgs = [
networkAccess ? '1' : '0',
req.cwd,
'--forbidden-manifest',
manifestPath,
command,
...args,
];
// 6. Create setup manifest if needed
let manifestPath: string | undefined;
if (pendingAcls.length > 0) {
manifestPath = path.join(
os.tmpdir(),
`gemini-cli-sandbox-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`,
);
fs.writeFileSync(manifestPath, pendingAcls.join('\n'), { mode: 0o600 });
}
const finalEnv = { ...sanitizedEnv };
return {
program,
args: finalArgs,
program: this.helperPath,
args: [
networkAccess ? '1' : '0',
req.cwd,
...(manifestPath ? ['--setup-manifest', manifestPath] : []),
command,
...args,
],
env: finalEnv,
cwd: req.cwd,
cleanup: () => {
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch {
// Ignore errors
if (manifestPath) {
try {
fs.unlinkSync(manifestPath);
} catch {
// Ignore cleanup errors
}
}
},
};
}
/**
* Grants "Low Mandatory Level" access to a path using icacls.
* Resolves a path and generates a Grant (L) or Deny (D) operation for the setup manifest.
* Checks for platform, caches, system directories, and existence (for Deny).
*/
private async grantLowIntegrityAccess(targetPath: string): Promise<void> {
if (os.platform() !== 'win32') {
return;
}
private async getLowIntegrityOp(
targetPath: string,
mode: 'L' | 'D',
): Promise<string | undefined> {
if (os.platform() !== 'win32') return undefined;
const resolvedPath = resolveToRealPath(targetPath);
if (this.allowedCache.has(resolvedPath)) {
return;
}
const resolved = resolveToRealPath(targetPath);
const cache = mode === 'L' ? this.allowedCache : this.deniedCache;
if (cache.has(resolved)) return undefined;
// Explicitly reject UNC paths to prevent credential theft/SSRF,
// but allow local extended-length and device paths.
// Security: Block UNC paths for Grants (prevents NTLM exfiltration/SSRF)
if (
resolvedPath.startsWith('\\\\') &&
!resolvedPath.startsWith('\\\\?\\') &&
!resolvedPath.startsWith('\\\\.\\')
mode === 'L' &&
resolved.startsWith('\\\\') &&
!resolved.startsWith('\\\\?\\') &&
!resolved.startsWith('\\\\.\\')
) {
debugLogger.log(
'WindowsSandboxManager: Rejecting UNC path for Low Integrity grant:',
resolvedPath,
'WindowsSandboxManager: Rejecting UNC path for grant:',
resolved,
);
return;
return undefined;
}
if (this.isSystemDirectory(resolvedPath)) {
return;
}
if (this.isSystemDirectory(resolved)) return undefined;
try {
// 1. Grant explicit Modify access to the Low Integrity SID
// 2. Set the Mandatory Label to Low to allow "Write Up" from Low processes
await spawnAsync('icacls', [
resolvedPath,
'/grant',
`${LOW_INTEGRITY_SID}:(OI)(CI)(M)`,
'/setintegritylevel',
'(OI)(CI)Low',
]);
this.allowedCache.add(resolvedPath);
} catch (e) {
debugLogger.log(
'WindowsSandboxManager: icacls failed for',
resolvedPath,
e,
);
}
}
/**
* Explicitly denies access to a path for Low Integrity processes using icacls.
*/
private async denyLowIntegrityAccess(targetPath: string): Promise<void> {
if (os.platform() !== 'win32') {
return;
}
const resolvedPath = resolveToRealPath(targetPath);
if (this.deniedCache.has(resolvedPath)) {
return;
}
// Never modify ACEs for system directories
if (this.isSystemDirectory(resolvedPath)) {
return;
}
// icacls flags: (OI) Object Inherit, (CI) Container Inherit, (F) Full Access Deny.
// Omit /T (recursive) for performance; (OI)(CI) ensures inheritance for new items.
// Windows dynamically evaluates existing items, though deep explicit Allow ACEs
// could potentially bypass this inherited Deny rule.
const DENY_ALL_INHERIT = '(OI)(CI)(F)';
// icacls fails on non-existent paths, so we cannot explicitly deny
// paths that do not yet exist (unlike macOS/Linux).
// Skip to prevent sandbox initialization failure.
try {
await fs.promises.stat(resolvedPath);
} catch (e: unknown) {
if (isNodeError(e) && e.code === 'ENOENT') {
return;
// Deny ops fail if the path doesn't exist
if (mode === 'D') {
try {
await fs.promises.stat(resolved);
} catch (e: unknown) {
if (isNodeError(e) && e.code === 'ENOENT') return undefined;
throw e;
}
throw e;
}
try {
await spawnAsync('icacls', [
resolvedPath,
'/deny',
`${LOW_INTEGRITY_SID}:${DENY_ALL_INHERIT}`,
]);
this.deniedCache.add(resolvedPath);
} catch (e) {
throw new Error(
`Failed to deny access to forbidden path: ${resolvedPath}. ${
e instanceof Error ? e.message : String(e)
}`,
);
}
cache.add(resolved);
return `${mode} ${resolved}`;
}
private isSystemDirectory(resolvedPath: string): boolean {
@@ -543,9 +407,44 @@ export class WindowsSandboxManager implements SandboxManager {
process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)';
return (
resolvedPath.toLowerCase().startsWith(systemRoot.toLowerCase()) ||
resolvedPath.toLowerCase().startsWith(programFiles.toLowerCase()) ||
resolvedPath.toLowerCase().startsWith(programFilesX86.toLowerCase())
isSubpath(systemRoot, resolvedPath) ||
isSubpath(programFiles, resolvedPath) ||
isSubpath(programFilesX86, resolvedPath)
);
}
/**
* Touches a file or directory to ensure it exists.
*/
private touch(filePath: string, isDirectory: boolean): void {
try {
if (isDirectory) {
if (!fs.existsSync(filePath)) {
fs.mkdirSync(filePath, { recursive: true });
}
} else {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, '');
}
}
} catch (e) {
debugLogger.log(`WindowsSandboxManager: Failed to touch ${filePath}:`, e);
}
}
parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined {
return parseWindowsSandboxDenials(result);
}
getWorkspace(): string {
return this.options.workspace;
}
getOptions(): GlobalSandboxOptions | undefined {
return this.options;
}
}
@@ -68,7 +68,7 @@ const Platform = {
/** Returns a path that is strictly outside the workspace and likely blocked. */
getExternalBlockedPath() {
return this.isWindows
? 'C:\\Windows\\System32\\drivers\\etc\\hosts'
? 'C:\\gemini_blocked_test.txt'
: '/Users/Shared/.gemini_test_blocked';
},
};
@@ -103,10 +103,9 @@ function ensureSandboxAvailable(): boolean {
const platform = os.platform();
if (platform === 'win32') {
// Windows sandboxing relies on icacls, which is a core system utility and
// always available.
// TODO: reenable once flakiness is addressed
return false;
// Windows sandboxing relies on the GeminiSandbox.exe helper, which is compiled
// using the built-in .NET Framework compiler (csc.exe) and is always available.
return true;
}
if (platform === 'darwin') {
+1 -3
View File
@@ -132,10 +132,9 @@ describe('EditTool', () => {
getDisableLLMCorrection: vi.fn(() => true),
getExperiments: () => {},
isPlanMode: vi.fn(() => false),
getPlansDir: vi.fn().mockReturnValue('/tmp/project/plans'),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
getPlansDir: vi.fn().mockReturnValue('/tmp/project/plans'),
getPlansDir: vi.fn().mockReturnValue('/tmp/plans'),
},
isPathAllowed(this: Config, absolutePath: string): boolean {
const workspaceContext = this.getWorkspaceContext();
@@ -1315,7 +1314,6 @@ function doIt() {
fs.mkdirSync(plansDir);
vi.mocked(mockConfig.isPlanMode).mockReturnValue(true);
vi.mocked(mockConfig.getPlansDir).mockReturnValue(plansDir);
vi.mocked(mockConfig.storage.getPlansDir).mockReturnValue(plansDir);
const filePath = path.join(rootDir, 'test-file.txt');
+4 -1
View File
@@ -465,7 +465,10 @@ class EditToolInvocation
);
if (this.config.isPlanMode()) {
const safeFilename = path.basename(this.params.file_path);
this.resolvedPath = path.join(this.config.getPlansDir(), safeFilename);
this.resolvedPath = path.join(
this.config.storage.getPlansDir(),
safeFilename,
);
} else if (!path.isAbsolute(this.params.file_path)) {
const result = correctPath(this.params.file_path, this.config);
if (result.success) {
@@ -11,6 +11,7 @@ import type { Config } from '../config/config.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { ToolConfirmationOutcome } from './tools.js';
import { ApprovalMode } from '../policy/types.js';
import fs from 'node:fs';
vi.mock('node:fs', async () => {
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
@@ -38,7 +39,6 @@ describe('EnterPlanModeTool', () => {
mockConfig = {
setApprovalMode: vi.fn(),
getPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'),
storage: {
getPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'),
} as unknown as Config['storage'],
@@ -119,6 +119,7 @@ describe('EnterPlanModeTool', () => {
describe('execute', () => {
it('should set approval mode to PLAN and return message', async () => {
const invocation = tool.build({});
vi.mocked(fs.existsSync).mockReturnValue(true);
const result = await invocation.execute(new AbortController().signal);
@@ -129,9 +130,21 @@ describe('EnterPlanModeTool', () => {
expect(result.returnDisplay).toBe('Switching to Plan mode');
});
it('should create plans directory if it does not exist', async () => {
const invocation = tool.build({});
vi.mocked(fs.existsSync).mockReturnValue(false);
await invocation.execute(new AbortController().signal);
expect(fs.mkdirSync).toHaveBeenCalledWith('/mock/plans/dir', {
recursive: true,
});
});
it('should include optional reason in output display but not in llmContent', async () => {
const reason = 'Design new database schema';
const invocation = tool.build({ reason });
vi.mocked(fs.existsSync).mockReturnValue(true);
const result = await invocation.execute(new AbortController().signal);
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import {
BaseDeclarativeTool,
BaseToolInvocation,
@@ -18,6 +19,7 @@ import { ENTER_PLAN_MODE_TOOL_NAME } from './tool-names.js';
import { ApprovalMode } from '../policy/types.js';
import { ENTER_PLAN_MODE_DEFINITION } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
import { debugLogger } from '../utils/debugLogger.js';
export interface EnterPlanModeParams {
reason?: string;
@@ -122,6 +124,19 @@ export class EnterPlanModeInvocation extends BaseToolInvocation<
this.config.setApprovalMode(ApprovalMode.PLAN);
// Ensure plans directory exists so that the agent can write the plan file.
// In sandboxed environments, the plans directory must exist on the host
// before it can be bound/allowed in the sandbox.
const plansDir = this.config.storage.getPlansDir();
if (!fs.existsSync(plansDir)) {
try {
fs.mkdirSync(plansDir, { recursive: true });
} catch (e) {
// Log error but don't fail; write_file will try again later
debugLogger.error(`Failed to create plans directory: ${plansDir}`, e);
}
}
return {
llmContent: 'Switching to Plan mode.',
returnDisplay: this.params.reason
@@ -44,7 +44,6 @@ describe('ExitPlanModeTool', () => {
getTargetDir: vi.fn().mockReturnValue(tempRootDir),
setApprovalMode: vi.fn(),
setApprovedPlanPath: vi.fn(),
getPlansDir: vi.fn().mockReturnValue(mockPlansDir),
storage: {
getPlansDir: vi.fn().mockReturnValue(mockPlansDir),
} as unknown as Config['storage'],
+8 -5
View File
@@ -60,8 +60,11 @@ export class ExitPlanModeTool extends BaseDeclarativeTool<
}
const safeFilename = path.basename(params.plan_filename);
const plansDir = resolveToRealPath(this.config.getPlansDir());
const resolvedPath = path.join(this.config.getPlansDir(), safeFilename);
const plansDir = resolveToRealPath(this.config.storage.getPlansDir());
const resolvedPath = path.join(
this.config.storage.getPlansDir(),
safeFilename,
);
const realPath = resolveToRealPath(resolvedPath);
@@ -117,7 +120,7 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
const pathError = await validatePlanPath(
this.params.plan_filename,
this.config.getPlansDir(),
this.config.storage.getPlansDir(),
);
if (pathError) {
this.planValidationError = pathError;
@@ -167,7 +170,7 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
}
getDescription(): string {
return `Requesting plan approval for: ${path.join(this.config.getPlansDir(), this.params.plan_filename)}`;
return `Requesting plan approval for: ${path.join(this.config.storage.getPlansDir(), this.params.plan_filename)}`;
}
/**
@@ -176,7 +179,7 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
*/
private getResolvedPlanPath(): string {
const safeFilename = path.basename(this.params.plan_filename);
return path.join(this.config.getPlansDir(), safeFilename);
return path.join(this.config.storage.getPlansDir(), safeFilename);
}
async execute(_signal: AbortSignal): Promise<ToolResult> {
@@ -237,7 +237,6 @@ describe('ToolRegistry', () => {
beforeEach(() => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.realpathSync).mockImplementation((p) => p.toString());
vi.mocked(fs.statSync).mockReturnValue({
isDirectory: () => true,
} as fs.Stats);
+1 -1
View File
@@ -617,7 +617,7 @@ export class ToolRegistry {
*/
getFunctionDeclarations(modelId?: string): FunctionDeclaration[] {
const isPlanMode = this.config.getApprovalMode() === ApprovalMode.PLAN;
const plansDir = this.config.getPlansDir();
const plansDir = this.config.storage.getPlansDir();
const declarations: FunctionDeclaration[] = [];
const seenNames = new Set<string>();
@@ -107,7 +107,6 @@ const mockConfigInternal = {
getDisableLLMCorrection: vi.fn(() => true),
isPlanMode: vi.fn(() => false),
getActiveModel: () => 'test-model',
getPlansDir: vi.fn().mockReturnValue('/tmp/plans'),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
},
+4 -1
View File
@@ -168,7 +168,10 @@ class WriteFileToolInvocation extends BaseToolInvocation<
if (this.config.isPlanMode()) {
const safeFilename = path.basename(this.params.file_path);
this.resolvedPath = path.join(this.config.getPlansDir(), safeFilename);
this.resolvedPath = path.join(
this.config.storage.getPlansDir(),
safeFilename,
);
} else {
this.resolvedPath = path.resolve(
this.config.getTargetDir(),
+16
View File
@@ -109,6 +109,13 @@ export interface HookEndPayload extends HookPayload {
success: boolean;
}
/**
* Payload for the 'hook-system-message' event.
*/
export interface HookSystemMessagePayload extends HookPayload {
message: string;
}
/**
* Payload for the 'retry-attempt' event.
*/
@@ -183,6 +190,7 @@ export enum CoreEvent {
SettingsChanged = 'settings-changed',
HookStart = 'hook-start',
HookEnd = 'hook-end',
HookSystemMessage = 'hook-system-message',
AgentsRefreshed = 'agents-refreshed',
AdminSettingsChanged = 'admin-settings-changed',
RetryAttempt = 'retry-attempt',
@@ -217,6 +225,7 @@ export interface CoreEvents extends ExtensionEvents {
[CoreEvent.SettingsChanged]: never[];
[CoreEvent.HookStart]: [HookStartPayload];
[CoreEvent.HookEnd]: [HookEndPayload];
[CoreEvent.HookSystemMessage]: [HookSystemMessagePayload];
[CoreEvent.AgentsRefreshed]: never[];
[CoreEvent.AdminSettingsChanged]: never[];
[CoreEvent.RetryAttempt]: [RetryAttemptPayload];
@@ -339,6 +348,13 @@ export class CoreEventEmitter extends EventEmitter<CoreEvents> {
this.emit(CoreEvent.HookEnd, payload);
}
/**
* Notifies subscribers that a hook has provided a system message.
*/
emitHookSystemMessage(payload: HookSystemMessagePayload): void {
this.emit(CoreEvent.HookSystemMessage, payload);
}
/**
* Notifies subscribers that agents have been refreshed.
*/
+3 -2
View File
@@ -36,7 +36,8 @@ function main() {
});
// Get changed files using the triple-dot syntax which correctly handles merge commits
const changedFiles = execSync(`git diff --name-only FETCH_HEAD...HEAD`, {
const head = process.env.PR_HEAD_SHA || 'HEAD';
const changedFiles = execSync(`git diff --name-only FETCH_HEAD...${head}`, {
encoding: 'utf-8',
})
.split('\n')
@@ -70,7 +71,7 @@ function main() {
if (coreChanges.length > 0) {
// Get the actual diff content for core files
const diff = execSync(
`git diff -U0 FETCH_HEAD...HEAD -- packages/core/src/`,
`git diff -U0 FETCH_HEAD...${head} -- packages/core/src/`,
{ encoding: 'utf-8' },
);
for (const sig of STEERING_SIGNATURES) {