mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-10 18:10:43 -07:00
fix(core): resolve nested plan directory duplication and relative path policies (#25138)
This commit is contained in:
@@ -31,30 +31,36 @@ describe('planUtils', () => {
|
||||
|
||||
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);
|
||||
const planPath = 'test.md';
|
||||
const fullPath = path.join(plansDir, planPath);
|
||||
fs.writeFileSync(fullPath, '# My Plan');
|
||||
|
||||
const result = await validatePlanPath(planPath, plansDir);
|
||||
const result = await validatePlanPath(planPath, plansDir, tempRootDir);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return error for non-existent file', async () => {
|
||||
const planPath = path.join('plans', 'ghost.md');
|
||||
const result = await validatePlanPath(planPath, plansDir);
|
||||
const planPath = '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');
|
||||
const maliciousPath = 'malicious.md';
|
||||
const fullMaliciousPath = path.join(plansDir, maliciousPath);
|
||||
|
||||
// Create a file outside the plans directory
|
||||
const outsideFile = path.join(tempRootDir, 'outside.md');
|
||||
fs.writeFileSync(outsideFile, 'secret content');
|
||||
|
||||
// Create a symbolic link pointing outside the plans directory
|
||||
fs.symlinkSync(outsideFile, fullMaliciousPath);
|
||||
|
||||
const result = await validatePlanPath(maliciousPath, plansDir);
|
||||
const result = await validatePlanPath(
|
||||
maliciousPath,
|
||||
plansDir,
|
||||
tempRootDir,
|
||||
);
|
||||
expect(result).toContain('Access denied');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,31 +22,86 @@ export const PlanErrorMessages = {
|
||||
READ_FAILURE: (detail: string) => `Failed to read plan file: ${detail}`,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Resolves a plan file path and strictly validates it against the plans directory boundary.
|
||||
* Useful for tools that need to write or read plans.
|
||||
* @param planPath The untrusted file path provided by the model.
|
||||
* @param plansDir The authorized project plans directory.
|
||||
* @returns The safely resolved path string.
|
||||
* @throws Error if the path is empty, malicious, or escapes boundaries.
|
||||
*/
|
||||
export function resolveAndValidatePlanPath(
|
||||
planPath: string,
|
||||
plansDir: string,
|
||||
projectRoot: string,
|
||||
): string {
|
||||
const trimmedPath = planPath.trim();
|
||||
if (!trimmedPath) {
|
||||
throw new Error('Plan file path must be non-empty.');
|
||||
}
|
||||
|
||||
// 1. Handle case where agent provided an absolute path
|
||||
if (path.isAbsolute(trimmedPath)) {
|
||||
if (
|
||||
isSubpath(resolveToRealPath(plansDir), resolveToRealPath(trimmedPath))
|
||||
) {
|
||||
return trimmedPath;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Handle case where agent provided a path relative to the project root
|
||||
const resolvedFromProjectRoot = path.resolve(projectRoot, trimmedPath);
|
||||
if (
|
||||
isSubpath(
|
||||
resolveToRealPath(plansDir),
|
||||
resolveToRealPath(resolvedFromProjectRoot),
|
||||
)
|
||||
) {
|
||||
return resolvedFromProjectRoot;
|
||||
}
|
||||
|
||||
// 3. Handle default case where agent provided a path relative to the plans directory
|
||||
const resolvedPath = path.resolve(plansDir, trimmedPath);
|
||||
const realPath = resolveToRealPath(resolvedPath);
|
||||
const realPlansDir = resolveToRealPath(plansDir);
|
||||
|
||||
if (!isSubpath(realPlansDir, realPath)) {
|
||||
throw new Error(
|
||||
PlanErrorMessages.PATH_ACCESS_DENIED(trimmedPath, plansDir),
|
||||
);
|
||||
}
|
||||
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* @param projectRoot The root directory of the project.
|
||||
* @returns An error message if validation fails, or null if successful.
|
||||
*/
|
||||
export async function validatePlanPath(
|
||||
planPath: string,
|
||||
plansDir: string,
|
||||
projectRoot: string,
|
||||
): Promise<string | null> {
|
||||
const safeFilename = path.basename(planPath);
|
||||
const resolvedPath = path.join(plansDir, safeFilename);
|
||||
const realPath = resolveToRealPath(resolvedPath);
|
||||
const realPlansDir = resolveToRealPath(plansDir);
|
||||
|
||||
if (!isSubpath(realPlansDir, realPath)) {
|
||||
return PlanErrorMessages.PATH_ACCESS_DENIED(planPath, realPlansDir);
|
||||
try {
|
||||
const resolvedPath = resolveAndValidatePlanPath(
|
||||
planPath,
|
||||
plansDir,
|
||||
projectRoot,
|
||||
);
|
||||
if (!(await fileExists(resolvedPath))) {
|
||||
return PlanErrorMessages.FILE_NOT_FOUND(planPath);
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return PlanErrorMessages.PATH_ACCESS_DENIED(
|
||||
planPath,
|
||||
resolveToRealPath(plansDir),
|
||||
);
|
||||
}
|
||||
|
||||
if (!(await fileExists(resolvedPath))) {
|
||||
return PlanErrorMessages.FILE_NOT_FOUND(planPath);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user