mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-01 20:51:00 -07:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 81238f893c | |||
| 68fef8745e | |||
| e432f7c009 | |||
| 846051f716 | |||
| 1c22c5b37b |
@@ -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)."
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -174,6 +174,7 @@ export type HistoryItemInfo = HistoryItemBase & {
|
||||
type: 'info';
|
||||
text: string;
|
||||
secondaryText?: string;
|
||||
source?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
marginBottom?: number;
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user