mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-05 08:33:58 -07:00
chore: Merge branch 'main' into channels
This commit is contained in:
@@ -89,9 +89,12 @@ export interface HookPayload {
|
||||
* Payload for the 'hook-start' event.
|
||||
*/
|
||||
export interface HookStartPayload extends HookPayload {
|
||||
/**
|
||||
* The source of the hook configuration.
|
||||
*/
|
||||
source?: string;
|
||||
/**
|
||||
* The 1-based index of the current hook in the execution sequence.
|
||||
* Used for progress indication (e.g. "Hook 1/3").
|
||||
*/
|
||||
hookIndex?: number;
|
||||
/**
|
||||
|
||||
@@ -35,19 +35,13 @@ describe('planUtils', () => {
|
||||
const fullPath = path.join(tempRootDir, planPath);
|
||||
fs.writeFileSync(fullPath, '# My Plan');
|
||||
|
||||
const result = await validatePlanPath(planPath, plansDir, tempRootDir);
|
||||
const result = await validatePlanPath(planPath, plansDir);
|
||||
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);
|
||||
const result = await validatePlanPath(planPath, plansDir);
|
||||
expect(result).toContain('Plan file does not exist');
|
||||
});
|
||||
|
||||
@@ -60,11 +54,7 @@ describe('planUtils', () => {
|
||||
// Create a symbolic link pointing outside the plans directory
|
||||
fs.symlinkSync(outsideFile, fullMaliciousPath);
|
||||
|
||||
const result = await validatePlanPath(
|
||||
maliciousPath,
|
||||
plansDir,
|
||||
tempRootDir,
|
||||
);
|
||||
const result = await validatePlanPath(maliciousPath, plansDir);
|
||||
expect(result).toContain('Access denied');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,8 +13,8 @@ import { isSubpath, resolveToRealPath } from './paths.js';
|
||||
* 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.',
|
||||
PATH_ACCESS_DENIED: (planPath: string, plansDir: string) =>
|
||||
`Access denied: plan path (${planPath}) must be within the designated plans directory (${plansDir}).`,
|
||||
FILE_NOT_FOUND: (path: string) =>
|
||||
`Plan file does not exist: ${path}. You must create the plan file before requesting approval.`,
|
||||
FILE_EMPTY:
|
||||
@@ -32,14 +32,14 @@ export const PlanErrorMessages = {
|
||||
export async function validatePlanPath(
|
||||
planPath: string,
|
||||
plansDir: string,
|
||||
targetDir: string,
|
||||
): Promise<string | null> {
|
||||
const resolvedPath = path.resolve(targetDir, planPath);
|
||||
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;
|
||||
return PlanErrorMessages.PATH_ACCESS_DENIED(planPath, realPlansDir);
|
||||
}
|
||||
|
||||
if (!(await fileExists(resolvedPath))) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
getShellConfiguration,
|
||||
initializeShellParsers,
|
||||
parseCommandDetails,
|
||||
splitCommands,
|
||||
stripShellWrapper,
|
||||
hasRedirection,
|
||||
resolveExecutable,
|
||||
@@ -119,8 +120,10 @@ describe('getCommandRoots', () => {
|
||||
expect(getCommandRoots('ls -l')).toEqual(['ls']);
|
||||
});
|
||||
|
||||
it('should handle paths and return the binary name', () => {
|
||||
expect(getCommandRoots('/usr/local/bin/node script.js')).toEqual(['node']);
|
||||
it('should handle paths and return the full path', () => {
|
||||
expect(getCommandRoots('/usr/local/bin/node script.js')).toEqual([
|
||||
'/usr/local/bin/node',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return an empty array for an empty string', () => {
|
||||
@@ -302,6 +305,40 @@ describeWindowsOnly('PowerShell integration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitCommands', () => {
|
||||
it('should split chained commands', () => {
|
||||
expect(splitCommands('ls -l && git status')).toEqual([
|
||||
'ls -l',
|
||||
'git status',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should filter out redirection tokens but keep command parts', () => {
|
||||
// Standard redirection
|
||||
expect(splitCommands('echo "hello" > file.txt')).toEqual(['echo "hello"']);
|
||||
expect(splitCommands('printf "test" >> log.txt')).toEqual([
|
||||
'printf "test"',
|
||||
]);
|
||||
expect(splitCommands('cat < input.txt')).toEqual(['cat']);
|
||||
|
||||
// Heredoc/Herestring
|
||||
expect(splitCommands('cat << EOF\nhello\nEOF')).toEqual(['cat']);
|
||||
// Note: The Tree-sitter bash parser includes the herestring in the main
|
||||
// command node's text, unlike standard redirections which are siblings.
|
||||
expect(splitCommands('grep "foo" <<< "foobar"')).toEqual([
|
||||
'grep "foo" <<< "foobar"',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should extract nested commands from process substitution while filtering the redirection operator', () => {
|
||||
// This is the key security test: we want cat to be checked, but not the > >(...) wrapper part
|
||||
const parts = splitCommands('echo "foo" > >(cat)');
|
||||
expect(parts).toContain('echo "foo"');
|
||||
expect(parts).toContain('cat');
|
||||
expect(parts.some((p) => p.includes('>'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripShellWrapper', () => {
|
||||
it('should strip sh -c with quotes', () => {
|
||||
expect(stripShellWrapper('sh -c "ls -l"')).toEqual('ls -l');
|
||||
|
||||
@@ -264,11 +264,7 @@ function normalizeCommandName(raw: string): string {
|
||||
return raw.slice(1, -1);
|
||||
}
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
return trimmed.split(/[\\/]/).pop() ?? trimmed;
|
||||
return raw.trim();
|
||||
}
|
||||
|
||||
function extractNameFromNode(node: Node): string | null {
|
||||
@@ -667,7 +663,10 @@ export function splitCommands(command: string): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
return parsed.details.map((detail) => detail.text).filter(Boolean);
|
||||
return parsed.details
|
||||
.filter((detail) => !REDIRECTION_NAMES.has(detail.name))
|
||||
.map((detail) => detail.text)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -705,7 +704,7 @@ export function getCommandRoots(command: string): string[] {
|
||||
|
||||
export function stripShellWrapper(command: string): string {
|
||||
const pattern =
|
||||
/^\s*(?:(?:sh|bash|zsh)\s+-c|cmd\.exe\s+\/c|powershell(?:\.exe)?\s+(?:-NoProfile\s+)?-Command|pwsh(?:\.exe)?\s+(?:-NoProfile\s+)?-Command)\s+/i;
|
||||
/^\s*(?:(?:(?:\S+\/)?(?:sh|bash|zsh))\s+-c|cmd\.exe\s+\/c|powershell(?:\.exe)?\s+(?:-NoProfile\s+)?-Command|pwsh(?:\.exe)?\s+(?:-NoProfile\s+)?-Command)\s+/i;
|
||||
const match = command.match(pattern);
|
||||
if (match) {
|
||||
let newCommand = command.substring(match[0].length).trim();
|
||||
|
||||
Reference in New Issue
Block a user