mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-22 19:14:33 -07:00
feat(core): improve shell redirection transparency and security (#16486)
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
||||
getCommandRoots,
|
||||
getShellConfiguration,
|
||||
initializeShellParsers,
|
||||
parseCommandDetails,
|
||||
stripShellWrapper,
|
||||
hasRedirection,
|
||||
resolveExecutable,
|
||||
@@ -168,6 +169,20 @@ describe('getCommandRoots', () => {
|
||||
expect(result).toEqual(['echo', 'cat']);
|
||||
});
|
||||
|
||||
it('should correctly identify input redirection with explicit file descriptor', () => {
|
||||
const result = parseCommandDetails('ls 2< input.txt');
|
||||
const redirection = result?.details.find((d) =>
|
||||
d.name.startsWith('redirection'),
|
||||
);
|
||||
expect(redirection?.name).toBe('redirection (<)');
|
||||
});
|
||||
|
||||
it('should filter out all redirections from getCommandRoots', () => {
|
||||
expect(getCommandRoots('cat < input.txt')).toEqual(['cat']);
|
||||
expect(getCommandRoots('ls 2> error.log')).toEqual(['ls']);
|
||||
expect(getCommandRoots('exec 3<&0')).toEqual(['exec']);
|
||||
});
|
||||
|
||||
it('should handle parser initialization failures gracefully', async () => {
|
||||
// Reset modules to clear singleton state
|
||||
vi.resetModules();
|
||||
@@ -220,6 +235,11 @@ describe('hasRedirection', () => {
|
||||
expect(hasRedirection('cat < input')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect redirection with explicit file descriptor', () => {
|
||||
expect(hasRedirection('ls 2> error.log')).toBe(true);
|
||||
expect(hasRedirection('exec 3<&0')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect append redirection', () => {
|
||||
expect(hasRedirection('echo hello >> world')).toBe(true);
|
||||
});
|
||||
@@ -242,6 +262,11 @@ describe('hasRedirection', () => {
|
||||
// A pipe is a 'pipeline' node.
|
||||
expect(hasRedirection('echo hello | cat')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when redirection characters are inside quotes in bash', () => {
|
||||
mockPlatform.mockReturnValue('linux');
|
||||
expect(hasRedirection('echo "a > b"')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describeWindowsOnly('PowerShell integration', () => {
|
||||
|
||||
@@ -141,6 +141,7 @@ export async function initializeShellParsers(): Promise<void> {
|
||||
export interface ParsedCommandDetail {
|
||||
name: string;
|
||||
text: string;
|
||||
startIndex: number;
|
||||
}
|
||||
|
||||
interface CommandParseResult {
|
||||
@@ -194,6 +195,13 @@ foreach ($commandAst in $commandAsts) {
|
||||
'utf16le',
|
||||
).toString('base64');
|
||||
|
||||
const REDIRECTION_NAMES = new Set([
|
||||
'redirection (<)',
|
||||
'redirection (>)',
|
||||
'heredoc (<<)',
|
||||
'herestring (<<<)',
|
||||
]);
|
||||
|
||||
function createParser(): Parser | null {
|
||||
if (!bashLanguage) {
|
||||
if (treeSitterInitializationError) {
|
||||
@@ -278,6 +286,24 @@ function extractNameFromNode(node: Node): string | null {
|
||||
}
|
||||
return normalizeCommandName(firstChild.text);
|
||||
}
|
||||
case 'file_redirect': {
|
||||
// The first child might be a file descriptor (e.g., '2>').
|
||||
// We iterate to find the actual operator token.
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (child && child.text.includes('<')) {
|
||||
return 'redirection (<)';
|
||||
}
|
||||
if (child && child.text.includes('>')) {
|
||||
return 'redirection (>)';
|
||||
}
|
||||
}
|
||||
return 'redirection (>)';
|
||||
}
|
||||
case 'heredoc_redirect':
|
||||
return 'heredoc (<<)';
|
||||
case 'herestring_redirect':
|
||||
return 'herestring (<<<)';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -293,43 +319,19 @@ function collectCommandDetails(
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop()!;
|
||||
|
||||
let name: string | null = null;
|
||||
let ignoreChildId: number | undefined;
|
||||
|
||||
if (current.type === 'redirected_statement') {
|
||||
const body = current.childForFieldName('body');
|
||||
if (body) {
|
||||
const bodyName = extractNameFromNode(body);
|
||||
if (bodyName) {
|
||||
name = bodyName;
|
||||
ignoreChildId = body.id;
|
||||
|
||||
// If we ignore the body node (because we used it to name the redirected_statement),
|
||||
// we must still traverse its children to find nested commands (e.g. command substitution).
|
||||
for (let i = body.namedChildCount - 1; i >= 0; i -= 1) {
|
||||
const grandChild = body.namedChild(i);
|
||||
if (grandChild) {
|
||||
stack.push(grandChild);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
name = extractNameFromNode(current);
|
||||
}
|
||||
|
||||
const name = extractNameFromNode(current);
|
||||
if (name) {
|
||||
details.push({
|
||||
name,
|
||||
text: source.slice(current.startIndex, current.endIndex).trim(),
|
||||
startIndex: current.startIndex,
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = current.namedChildCount - 1; i >= 0; i -= 1) {
|
||||
const child = current.namedChild(i);
|
||||
if (child && child.id !== ignoreChildId) {
|
||||
// Traverse all children to find all sub-components (commands, redirections, etc.)
|
||||
for (let i = current.childCount - 1; i >= 0; i -= 1) {
|
||||
const child = current.child(i);
|
||||
if (child) {
|
||||
stack.push(child);
|
||||
}
|
||||
}
|
||||
@@ -424,7 +426,7 @@ function parseBashCommandDetails(command: string): CommandParseResult | null {
|
||||
}
|
||||
}
|
||||
return {
|
||||
details,
|
||||
details: details.sort((a, b) => a.startIndex - b.startIndex),
|
||||
hasError,
|
||||
};
|
||||
}
|
||||
@@ -499,6 +501,7 @@ function parsePowerShellCommandDetails(
|
||||
return {
|
||||
name,
|
||||
text,
|
||||
startIndex: 0,
|
||||
};
|
||||
})
|
||||
.filter((detail): detail is ParsedCommandDetail => detail !== null);
|
||||
@@ -610,6 +613,12 @@ export function escapeShellArg(arg: string, shell: ShellType): string {
|
||||
*/
|
||||
export function hasRedirection(command: string): boolean {
|
||||
const fallbackCheck = () => /[><]/.test(command);
|
||||
|
||||
// If there are no redirection characters at all, we can skip parsing.
|
||||
if (!fallbackCheck()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const configuration = getShellConfiguration();
|
||||
|
||||
if (configuration.shell === 'powershell') {
|
||||
@@ -684,7 +693,10 @@ export function getCommandRoots(command: string): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
return parsed.details.map((detail) => detail.name).filter(Boolean);
|
||||
return parsed.details
|
||||
.map((detail) => detail.name)
|
||||
.filter((name) => !REDIRECTION_NAMES.has(name))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function stripShellWrapper(command: string): string {
|
||||
|
||||
Reference in New Issue
Block a user