mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-22 17:53:04 -07:00
470 lines
11 KiB
TypeScript
470 lines
11 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2026 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
import { parse as shellParse } from 'shell-quote';
|
|
|
|
/**
|
|
* Checks if a command with its arguments is known to be safe to execute
|
|
* without requiring user confirmation. This is primarily used to allow
|
|
* harmless, read-only commands to run silently in the macOS sandbox.
|
|
*
|
|
* It handles raw command execution as well as wrapped commands like `bash -c "..."` or `bash -lc "..."`.
|
|
* For wrapped commands, it parses the script and ensures all individual
|
|
* sub-commands are in the known-safe list and no dangerous shell operators
|
|
* (like subshells or redirection) are used.
|
|
*
|
|
* @param args - The command and its arguments (e.g., ['ls', '-la'])
|
|
* @returns true if the command is considered safe, false otherwise.
|
|
*/
|
|
export function isKnownSafeCommand(args: string[]): boolean {
|
|
if (!args || args.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
// Normalize zsh to bash
|
|
const normalizedArgs = args.map((a) => (a === 'zsh' ? 'bash' : a));
|
|
|
|
if (isSafeToCallWithExec(normalizedArgs)) {
|
|
return true;
|
|
}
|
|
|
|
// Support `bash -lc "..."`
|
|
if (
|
|
normalizedArgs.length === 3 &&
|
|
normalizedArgs[0] === 'bash' &&
|
|
(normalizedArgs[1] === '-lc' || normalizedArgs[1] === '-c')
|
|
) {
|
|
try {
|
|
const script = normalizedArgs[2];
|
|
|
|
// Basic check for dangerous operators that could spawn subshells or redirect output
|
|
// We allow &&, ||, |, ; but explicitly block subshells () and redirection >, >>, <
|
|
if (/[()<>]/g.test(script)) {
|
|
return false;
|
|
}
|
|
|
|
const commands = script.split(/&&|\|\||\||;/);
|
|
|
|
let allSafe = true;
|
|
for (const cmd of commands) {
|
|
const trimmed = cmd.trim();
|
|
if (!trimmed) continue;
|
|
|
|
const parsed = shellParse(trimmed).map(String);
|
|
if (parsed.length === 0) continue;
|
|
|
|
if (!isSafeToCallWithExec(parsed)) {
|
|
allSafe = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (allSafe && commands.length > 0) {
|
|
return true;
|
|
}
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Core validation logic that checks a single command and its arguments
|
|
* against an allowlist of known safe operations. It performs deep validation
|
|
* for specific tools like `base64`, `find`, `rg`, `git`, and `sed` to ensure
|
|
* unsafe flags (like `--output`, `-exec`, or mutating options) are not used.
|
|
*
|
|
* @param args - The command and its arguments.
|
|
* @returns true if the command is strictly read-only and safe.
|
|
*/
|
|
function isSafeToCallWithExec(args: string[]): boolean {
|
|
if (!args || args.length === 0) return false;
|
|
const cmd = args[0];
|
|
|
|
const safeCommands = new Set([
|
|
'cat',
|
|
'cd',
|
|
'cut',
|
|
'echo',
|
|
'expr',
|
|
'false',
|
|
'grep',
|
|
'head',
|
|
'id',
|
|
'ls',
|
|
'nl',
|
|
'paste',
|
|
'pwd',
|
|
'rev',
|
|
'seq',
|
|
'stat',
|
|
'tail',
|
|
'tr',
|
|
'true',
|
|
'uname',
|
|
'uniq',
|
|
'wc',
|
|
'which',
|
|
'whoami',
|
|
'numfmt',
|
|
'tac',
|
|
]);
|
|
|
|
if (safeCommands.has(cmd)) {
|
|
return true;
|
|
}
|
|
|
|
if (cmd === 'base64') {
|
|
const unsafeOptions = new Set(['-o', '--output']);
|
|
return !args
|
|
.slice(1)
|
|
.some(
|
|
(arg) =>
|
|
unsafeOptions.has(arg) ||
|
|
arg.startsWith('--output=') ||
|
|
(arg.startsWith('-o') && arg !== '-o'),
|
|
);
|
|
}
|
|
|
|
if (cmd === 'find') {
|
|
const unsafeOptions = new Set([
|
|
'-exec',
|
|
'-execdir',
|
|
'-ok',
|
|
'-okdir',
|
|
'-delete',
|
|
'-fls',
|
|
'-fprint',
|
|
'-fprint0',
|
|
'-fprintf',
|
|
]);
|
|
return !args.some((arg) => unsafeOptions.has(arg));
|
|
}
|
|
|
|
if (cmd === 'rg') {
|
|
const unsafeWithArgs = new Set(['--pre', '--hostname-bin']);
|
|
const unsafeWithoutArgs = new Set(['--search-zip', '-z']);
|
|
|
|
return !args.some((arg) => {
|
|
if (unsafeWithoutArgs.has(arg)) return true;
|
|
for (const opt of unsafeWithArgs) {
|
|
if (arg === opt || arg.startsWith(opt + '=')) return true;
|
|
}
|
|
return false;
|
|
});
|
|
}
|
|
|
|
if (cmd === 'git') {
|
|
if (gitHasConfigOverrideGlobalOption(args)) {
|
|
return false;
|
|
}
|
|
|
|
const { idx, subcommand } = findGitSubcommand(args, [
|
|
'status',
|
|
'log',
|
|
'diff',
|
|
'show',
|
|
'branch',
|
|
]);
|
|
if (!subcommand) {
|
|
return false;
|
|
}
|
|
|
|
const subcommandArgs = args.slice(idx + 1);
|
|
|
|
if (['status', 'log', 'diff', 'show'].includes(subcommand)) {
|
|
return gitSubcommandArgsAreReadOnly(subcommandArgs);
|
|
}
|
|
|
|
if (subcommand === 'branch') {
|
|
return (
|
|
gitSubcommandArgsAreReadOnly(subcommandArgs) &&
|
|
gitBranchIsReadOnly(subcommandArgs)
|
|
);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
if (cmd === 'sed') {
|
|
// Special-case sed -n {N|M,N}p
|
|
if (args.length <= 4 && args[1] === '-n' && isValidSedNArg(args[2])) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Helper to identify which git subcommand is being executed, skipping over
|
|
* global git options like `-c` or `--git-dir`.
|
|
*
|
|
* @param args - The full git command arguments.
|
|
* @param subcommands - A list of subcommands to look for.
|
|
* @returns An object containing the index of the subcommand and its name.
|
|
*/
|
|
function findGitSubcommand(
|
|
args: string[],
|
|
subcommands: string[],
|
|
): { idx: number; subcommand: string | null } {
|
|
let skipNext = false;
|
|
|
|
for (let idx = 1; idx < args.length; idx++) {
|
|
if (skipNext) {
|
|
skipNext = false;
|
|
continue;
|
|
}
|
|
|
|
const arg = args[idx];
|
|
|
|
if (
|
|
arg.startsWith('--config-env=') ||
|
|
arg.startsWith('--exec-path=') ||
|
|
arg.startsWith('--git-dir=') ||
|
|
arg.startsWith('--namespace=') ||
|
|
arg.startsWith('--super-prefix=') ||
|
|
arg.startsWith('--work-tree=') ||
|
|
((arg.startsWith('-C') || arg.startsWith('-c')) && arg.length > 2)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
arg === '-C' ||
|
|
arg === '-c' ||
|
|
arg === '--config-env' ||
|
|
arg === '--exec-path' ||
|
|
arg === '--git-dir' ||
|
|
arg === '--namespace' ||
|
|
arg === '--super-prefix' ||
|
|
arg === '--work-tree'
|
|
) {
|
|
skipNext = true;
|
|
continue;
|
|
}
|
|
|
|
if (arg === '--' || arg.startsWith('-')) {
|
|
continue;
|
|
}
|
|
|
|
if (subcommands.includes(arg)) {
|
|
return { idx, subcommand: arg };
|
|
}
|
|
|
|
return { idx: -1, subcommand: null };
|
|
}
|
|
|
|
return { idx: -1, subcommand: null };
|
|
}
|
|
|
|
/**
|
|
* Checks if a git command contains global configuration override flags
|
|
* (e.g., `-c` or `--config-env`) which could be used maliciously to
|
|
* execute arbitrary code via git config.
|
|
*
|
|
* @param args - The git command arguments.
|
|
* @returns true if config overrides are present.
|
|
*/
|
|
function gitHasConfigOverrideGlobalOption(args: string[]): boolean {
|
|
return args.some(
|
|
(arg) =>
|
|
arg === '-c' ||
|
|
arg === '--config-env' ||
|
|
(arg.startsWith('-c') && arg.length > 2) ||
|
|
arg.startsWith('--config-env='),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Validates that the arguments for safe git subcommands (like `status`, `log`,
|
|
* `diff`, `show`) do not contain flags that could cause mutations or execute
|
|
* arbitrary commands (e.g., `--output`, `--exec`).
|
|
*
|
|
* @param args - Arguments passed to the git subcommand.
|
|
* @returns true if the arguments only represent read-only operations.
|
|
*/
|
|
function gitSubcommandArgsAreReadOnly(args: string[]): boolean {
|
|
const unsafeFlags = new Set([
|
|
'--output',
|
|
'--ext-diff',
|
|
'--textconv',
|
|
'--exec',
|
|
'--paginate',
|
|
]);
|
|
|
|
return !args.some(
|
|
(arg) =>
|
|
unsafeFlags.has(arg) ||
|
|
arg.startsWith('--output=') ||
|
|
arg.startsWith('--exec='),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Validates that `git branch` is only used for read operations
|
|
* (e.g., listing branches) rather than creating, deleting, or renaming branches.
|
|
*
|
|
* @param args - Arguments passed to `git branch`.
|
|
* @returns true if it's purely a listing/read-only branch command.
|
|
*/
|
|
function gitBranchIsReadOnly(args: string[]): boolean {
|
|
if (args.length === 0) return true;
|
|
|
|
let sawReadOnlyFlag = false;
|
|
for (const arg of args) {
|
|
if (
|
|
[
|
|
'--list',
|
|
'-l',
|
|
'--show-current',
|
|
'-a',
|
|
'--all',
|
|
'-r',
|
|
'--remotes',
|
|
'-v',
|
|
'-vv',
|
|
'--verbose',
|
|
].includes(arg)
|
|
) {
|
|
sawReadOnlyFlag = true;
|
|
} else if (arg.startsWith('--format=')) {
|
|
sawReadOnlyFlag = true;
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
return sawReadOnlyFlag;
|
|
}
|
|
|
|
/**
|
|
* Ensures that a `sed` command argument is a valid line-printing instruction
|
|
* (e.g., `10p` or `5,10p`), preventing unsafe script execution in `sed`.
|
|
*
|
|
* @param arg - The script argument passed to `sed -n`.
|
|
* @returns true if it's a valid, safe print command.
|
|
*/
|
|
function isValidSedNArg(arg: string | undefined): boolean {
|
|
if (!arg) return false;
|
|
|
|
if (!arg.endsWith('p')) return false;
|
|
const core = arg.slice(0, -1);
|
|
|
|
const parts = core.split(',');
|
|
if (parts.length === 1) {
|
|
const num = parts[0];
|
|
return num.length > 0 && /^\d+$/.test(num);
|
|
} else if (parts.length === 2) {
|
|
const a = parts[0];
|
|
const b = parts[1];
|
|
return a.length > 0 && b.length > 0 && /^\d+$/.test(a) && /^\d+$/.test(b);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Checks if a command with its arguments is explicitly known to be dangerous
|
|
* and should be blocked or require strict user confirmation. This catches
|
|
* destructive commands like `rm -rf`, `sudo`, and commands with execution
|
|
* flags like `find -exec`.
|
|
*
|
|
* @param args - The command and its arguments.
|
|
* @returns true if the command is identified as dangerous, false otherwise.
|
|
*/
|
|
export function isDangerousCommand(args: string[]): boolean {
|
|
if (!args || args.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
const cmd = args[0];
|
|
|
|
if (cmd === 'rm') {
|
|
return args[1] === '-f' || args[1] === '-rf' || args[1] === '-fr';
|
|
}
|
|
|
|
if (cmd === 'sudo') {
|
|
return isDangerousCommand(args.slice(1));
|
|
}
|
|
|
|
if (cmd === 'find') {
|
|
const unsafeOptions = new Set([
|
|
'-exec',
|
|
'-execdir',
|
|
'-ok',
|
|
'-okdir',
|
|
'-delete',
|
|
'-fls',
|
|
'-fprint',
|
|
'-fprint0',
|
|
'-fprintf',
|
|
]);
|
|
return args.some((arg) => unsafeOptions.has(arg));
|
|
}
|
|
|
|
if (cmd === 'rg') {
|
|
const unsafeWithArgs = new Set(['--pre', '--hostname-bin']);
|
|
const unsafeWithoutArgs = new Set(['--search-zip', '-z']);
|
|
|
|
return args.some((arg) => {
|
|
if (unsafeWithoutArgs.has(arg)) return true;
|
|
for (const opt of unsafeWithArgs) {
|
|
if (arg === opt || arg.startsWith(opt + '=')) return true;
|
|
}
|
|
return false;
|
|
});
|
|
}
|
|
|
|
if (cmd === 'git') {
|
|
if (gitHasConfigOverrideGlobalOption(args)) {
|
|
return true;
|
|
}
|
|
|
|
const { idx, subcommand } = findGitSubcommand(args, [
|
|
'status',
|
|
'log',
|
|
'diff',
|
|
'show',
|
|
'branch',
|
|
]);
|
|
if (!subcommand) {
|
|
// It's a git command we don't recognize as explicitly safe.
|
|
return false;
|
|
}
|
|
|
|
const subcommandArgs = args.slice(idx + 1);
|
|
|
|
if (['status', 'log', 'diff', 'show'].includes(subcommand)) {
|
|
return !gitSubcommandArgsAreReadOnly(subcommandArgs);
|
|
}
|
|
|
|
if (subcommand === 'branch') {
|
|
return !(
|
|
gitSubcommandArgsAreReadOnly(subcommandArgs) &&
|
|
gitBranchIsReadOnly(subcommandArgs)
|
|
);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
if (cmd === 'base64') {
|
|
const unsafeOptions = new Set(['-o', '--output']);
|
|
return args
|
|
.slice(1)
|
|
.some(
|
|
(arg) =>
|
|
unsafeOptions.has(arg) ||
|
|
arg.startsWith('--output=') ||
|
|
(arg.startsWith('-o') && arg !== '-o'),
|
|
);
|
|
}
|
|
|
|
return false;
|
|
}
|