mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-08 12:20:38 -07:00
feat(plan): add core logic and exit_plan_mode tool definition (#18110)
This commit is contained in:
@@ -34,6 +34,8 @@ import {
|
||||
readWasmBinaryFromDisk,
|
||||
saveTruncatedToolOutput,
|
||||
formatTruncatedToolOutput,
|
||||
getRealPath,
|
||||
isEmpty,
|
||||
} from './fileUtils.js';
|
||||
import { StandardFileSystemService } from '../services/fileSystemService.js';
|
||||
|
||||
@@ -172,6 +174,47 @@ describe('fileUtils', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('getRealPath', () => {
|
||||
it('should resolve a real path for an existing file', () => {
|
||||
const testFile = path.join(tempRootDir, 'real.txt');
|
||||
actualNodeFs.writeFileSync(testFile, 'content');
|
||||
expect(getRealPath(testFile)).toBe(actualNodeFs.realpathSync(testFile));
|
||||
});
|
||||
|
||||
it('should return absolute resolved path for a non-existent file', () => {
|
||||
const ghostFile = path.join(tempRootDir, 'ghost.txt');
|
||||
expect(getRealPath(ghostFile)).toBe(path.resolve(ghostFile));
|
||||
});
|
||||
|
||||
it('should resolve symbolic links', () => {
|
||||
const targetFile = path.join(tempRootDir, 'target.txt');
|
||||
const linkFile = path.join(tempRootDir, 'link.txt');
|
||||
actualNodeFs.writeFileSync(targetFile, 'content');
|
||||
actualNodeFs.symlinkSync(targetFile, linkFile);
|
||||
|
||||
expect(getRealPath(linkFile)).toBe(actualNodeFs.realpathSync(targetFile));
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEmpty', () => {
|
||||
it('should return false for a non-empty file', async () => {
|
||||
const testFile = path.join(tempRootDir, 'full.txt');
|
||||
actualNodeFs.writeFileSync(testFile, 'some content');
|
||||
expect(await isEmpty(testFile)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for an empty file', async () => {
|
||||
const testFile = path.join(tempRootDir, 'empty.txt');
|
||||
actualNodeFs.writeFileSync(testFile, ' ');
|
||||
expect(await isEmpty(testFile)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for a non-existent file (defensive)', async () => {
|
||||
const testFile = path.join(tempRootDir, 'ghost.txt');
|
||||
expect(await isEmpty(testFile)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fileExists', () => {
|
||||
it('should return true if the file exists', async () => {
|
||||
const testFile = path.join(tempRootDir, 'exists.txt');
|
||||
|
||||
@@ -228,6 +228,55 @@ export function isWithinRoot(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely resolves a path to its real path if it exists, otherwise returns the absolute resolved path.
|
||||
*/
|
||||
export function getRealPath(filePath: string): string {
|
||||
try {
|
||||
return fs.realpathSync(filePath);
|
||||
} catch {
|
||||
return path.resolve(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a file's content is empty or contains only whitespace.
|
||||
* Efficiently checks file size first, and only samples the beginning of the file.
|
||||
* Honors Unicode BOM encodings.
|
||||
*/
|
||||
export async function isEmpty(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const stats = await fsPromises.stat(filePath);
|
||||
if (stats.size === 0) return true;
|
||||
|
||||
// Sample up to 1KB to check for non-whitespace content.
|
||||
// If a file is larger than 1KB and contains only whitespace,
|
||||
// it's an extreme edge case we can afford to read slightly more of if needed,
|
||||
// but for most valid plans/files, this is sufficient.
|
||||
const fd = await fsPromises.open(filePath, 'r');
|
||||
try {
|
||||
const { buffer } = await fd.read({
|
||||
buffer: Buffer.alloc(Math.min(1024, stats.size)),
|
||||
offset: 0,
|
||||
length: Math.min(1024, stats.size),
|
||||
position: 0,
|
||||
});
|
||||
|
||||
const bom = detectBOM(buffer);
|
||||
const content = bom
|
||||
? buffer.subarray(bom.bomLength).toString('utf8')
|
||||
: buffer.toString('utf8');
|
||||
|
||||
return content.trim().length === 0;
|
||||
} finally {
|
||||
await fd.close();
|
||||
}
|
||||
} catch {
|
||||
// If file is unreadable, we treat it as empty/invalid for validation purposes
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic: determine if a file is likely binary.
|
||||
* Now BOM-aware: if a Unicode BOM is detected, we treat it as text.
|
||||
|
||||
95
packages/core/src/utils/planUtils.test.ts
Normal file
95
packages/core/src/utils/planUtils.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import * as fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import { validatePlanPath, validatePlanContent } from './planUtils.js';
|
||||
|
||||
describe('planUtils', () => {
|
||||
let tempRootDir: string;
|
||||
let plansDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempRootDir = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'planUtils-test-')),
|
||||
);
|
||||
const plansDirRaw = path.join(tempRootDir, 'plans');
|
||||
fs.mkdirSync(plansDirRaw, { recursive: true });
|
||||
plansDir = fs.realpathSync(plansDirRaw);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(tempRootDir)) {
|
||||
fs.rmSync(tempRootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('validatePlanPath', () => {
|
||||
it('should return null for a valid path within plans directory', async () => {
|
||||
const planPath = path.join('plans', 'test.md');
|
||||
const fullPath = path.join(tempRootDir, planPath);
|
||||
fs.writeFileSync(fullPath, '# My Plan');
|
||||
|
||||
const result = await validatePlanPath(planPath, plansDir, tempRootDir);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return error for path traversal', async () => {
|
||||
const planPath = path.join('..', 'secret.txt');
|
||||
const result = await validatePlanPath(planPath, plansDir, tempRootDir);
|
||||
expect(result).toContain('Access denied');
|
||||
});
|
||||
|
||||
it('should return error for non-existent file', async () => {
|
||||
const planPath = path.join('plans', 'ghost.md');
|
||||
const result = await validatePlanPath(planPath, plansDir, tempRootDir);
|
||||
expect(result).toContain('Plan file does not exist');
|
||||
});
|
||||
|
||||
it('should detect path traversal via symbolic links', async () => {
|
||||
const maliciousPath = path.join('plans', 'malicious.md');
|
||||
const fullMaliciousPath = path.join(tempRootDir, maliciousPath);
|
||||
const outsideFile = path.join(tempRootDir, 'outside.txt');
|
||||
fs.writeFileSync(outsideFile, 'secret content');
|
||||
|
||||
// Create a symbolic link pointing outside the plans directory
|
||||
fs.symlinkSync(outsideFile, fullMaliciousPath);
|
||||
|
||||
const result = await validatePlanPath(
|
||||
maliciousPath,
|
||||
plansDir,
|
||||
tempRootDir,
|
||||
);
|
||||
expect(result).toContain('Access denied');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePlanContent', () => {
|
||||
it('should return null for non-empty content', async () => {
|
||||
const planPath = path.join(plansDir, 'full.md');
|
||||
fs.writeFileSync(planPath, 'some content');
|
||||
const result = await validatePlanContent(planPath);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return error for empty content', async () => {
|
||||
const planPath = path.join(plansDir, 'empty.md');
|
||||
fs.writeFileSync(planPath, ' ');
|
||||
const result = await validatePlanContent(planPath);
|
||||
expect(result).toContain('Plan file is empty');
|
||||
});
|
||||
|
||||
it('should return error for unreadable file', async () => {
|
||||
const planPath = path.join(plansDir, 'ghost.md');
|
||||
const result = await validatePlanContent(planPath);
|
||||
// Since isEmpty treats unreadable files as empty (defensive),
|
||||
// we expect the "Plan file is empty" message.
|
||||
expect(result).toContain('Plan file is empty');
|
||||
});
|
||||
});
|
||||
});
|
||||
69
packages/core/src/utils/planUtils.ts
Normal file
69
packages/core/src/utils/planUtils.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import { isEmpty, fileExists } from './fileUtils.js';
|
||||
import { isSubpath, resolveToRealPath } from './paths.js';
|
||||
|
||||
/**
|
||||
* Standard error messages for the plan approval workflow.
|
||||
* Shared between backend tools and CLI UI for consistency.
|
||||
*/
|
||||
export const PlanErrorMessages = {
|
||||
PATH_ACCESS_DENIED:
|
||||
'Access denied: plan path must be within the designated plans directory.',
|
||||
FILE_NOT_FOUND: (path: string) =>
|
||||
`Plan file does not exist: ${path}. You must create the plan file before requesting approval.`,
|
||||
FILE_EMPTY:
|
||||
'Plan file is empty. You must write content to the plan file before requesting approval.',
|
||||
READ_FAILURE: (detail: string) => `Failed to read plan file: ${detail}`,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Validates a plan file path for safety (traversal) and existence.
|
||||
* @param planPath The untrusted path to the plan file.
|
||||
* @param plansDir The authorized project plans directory.
|
||||
* @param targetDir The current working directory (project root).
|
||||
* @returns An error message if validation fails, or null if successful.
|
||||
*/
|
||||
export async function validatePlanPath(
|
||||
planPath: string,
|
||||
plansDir: string,
|
||||
targetDir: string,
|
||||
): Promise<string | null> {
|
||||
const resolvedPath = path.resolve(targetDir, planPath);
|
||||
const realPath = resolveToRealPath(resolvedPath);
|
||||
const realPlansDir = resolveToRealPath(plansDir);
|
||||
|
||||
if (!isSubpath(realPlansDir, realPath)) {
|
||||
return PlanErrorMessages.PATH_ACCESS_DENIED;
|
||||
}
|
||||
|
||||
if (!(await fileExists(resolvedPath))) {
|
||||
return PlanErrorMessages.FILE_NOT_FOUND(planPath);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a plan file has non-empty content.
|
||||
* @param planPath The path to the plan file.
|
||||
* @returns An error message if the file is empty or unreadable, or null if successful.
|
||||
*/
|
||||
export async function validatePlanContent(
|
||||
planPath: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
if (await isEmpty(planPath)) {
|
||||
return PlanErrorMessages.FILE_EMPTY;
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return PlanErrorMessages.READ_FAILURE(message);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user