gemini-cli / packages /core /src /sandbox /utils /commandSafety.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
7a1ad33 verified
Raw History Blame Contribute Delete
22.8 kB
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { parse as shellParse } from 'shell-quote';
import {
extractStringFromParseEntry,
initializeShellParsers,
splitCommands,
stripShellWrapper,
} from '../../utils/shell-utils.js';
import {
isSubpath,
isTrustedSystemPath,
resolveToRealPath,
} from '../../utils/paths.js';
function isRipgrepCommand(cmd: string): boolean {
const cmdBasename = path.basename(cmd);
return cmdBasename === 'rg' || cmdBasename === 'rg.exe';
}
function isTrustedCommandPath(cmd: string): boolean {
if (!path.isAbsolute(cmd)) {
return false;
}
try {
const realPath = resolveToRealPath(cmd);
return isTrustedSystemPath(realPath);
} catch {
return false;
}
}
/**
* Determines if a command is strictly approved for execution on macOS.
* A command is approved if it's composed entirely of tools explicitly listed in `approvedTools`
* OR if it's composed of known safe, read-only POSIX commands.
*
* @param command - The full command string to execute.
* @param args - The arguments for the command.
* @param approvedTools - A list of explicitly approved tool names (e.g., ['npm', 'git']).
* @returns true if the command is strictly approved, false otherwise.
*/
export async function isStrictlyApproved(
command: string,
args: string[],
approvedTools?: string[],
cwd?: string,
workspaceRoot?: string,
): Promise<boolean> {
const tools = approvedTools ?? [];
await initializeShellParsers();
const fullCmd = [command, ...args].join(' ');
const stripped = stripShellWrapper(fullCmd);
const pipelineCommands = splitCommands(stripped);
// Fallback for simple commands or parsing failures
if (pipelineCommands.length === 0) {
// For simple commands, we check the root command.
// If it's explicitly approved OR it's a known safe POSIX command, we allow it.
return (
tools.includes(command) ||
isKnownSafeCommand([command, ...args], cwd, workspaceRoot)
);
}
// Check every segment of the pipeline
return pipelineCommands.every((cmdString) => {
const trimmed = cmdString.trim();
if (!trimmed) return true;
const parsedArgs = shellParse(trimmed).map(extractStringFromParseEntry);
if (parsedArgs.length === 0) return true;
const root = parsedArgs[0];
// The segment is approved if the root tool is in the allowlist OR if the whole segment is safe.
return (
tools.includes(root) || isKnownSafeCommand(parsedArgs, cwd, workspaceRoot)
);
});
}
/**
* Checks whether an argument provided to a file-reading command points outside the workspace
* or resolves via symbolic link to a target outside the workspace.
*/
function isPathEscapingWorkspace(
arg: string,
workspaceRoot: string,
cwd: string,
): boolean {
if (!arg || typeof arg !== 'string') return false;
// Unresolved shell variables or backticks cannot be statically verified
if (arg.includes('$') || arg.includes('`')) {
return true;
}
let target: string;
if (arg === '~' || arg.startsWith('~/') || arg.startsWith('~\\')) {
const homeDir = os.homedir();
target = path.resolve(homeDir, arg.slice(2));
if (!isSubpath(workspaceRoot, target)) {
return true;
}
} else if (arg.startsWith('~')) {
return true;
} else if (path.isAbsolute(arg)) {
target = path.resolve(arg);
if (!isSubpath(workspaceRoot, target)) {
return true;
}
} else {
target = path.resolve(cwd, arg);
if (!isSubpath(workspaceRoot, target)) {
return true;
}
}
// Check if target or any parent directory resolves through a symlink to outside the workspace
let curr = target;
while (
curr &&
isSubpath(workspaceRoot, curr) &&
curr !== path.dirname(curr)
) {
try {
const stat = fs.lstatSync(curr, { throwIfNoEntry: false });
if (stat?.isSymbolicLink()) {
const real = fs.realpathSync(curr);
if (!isSubpath(workspaceRoot, real)) {
return true;
}
}
} catch {
// ignore
}
curr = path.dirname(curr);
}
return false;
}
/**
* 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'])
* @param cwd - Optional working directory for resolving relative paths
* @param workspaceRoot - Optional workspace root directory
* @returns true if the command is considered safe, false otherwise.
*/
export function isKnownSafeCommand(
args: string[],
cwd?: string,
workspaceRoot?: 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, cwd, workspaceRoot)) {
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 = splitCommands(script);
if (commands.length === 0) return false;
return commands.every((cmd) => {
const trimmed = cmd.trim();
if (!trimmed) return true;
const parsed = shellParse(trimmed).map(extractStringFromParseEntry);
if (parsed.length === 0) return true;
return isSafeToCallWithExec(parsed, cwd, workspaceRoot);
});
} 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,
* and ensures all path arguments remain strictly within the workspace.
*
* @param args - The command and its arguments.
* @param cwd - Optional working directory for relative path resolution.
* @param workspaceRoot - Optional workspace boundary.
* @returns true if the command is strictly read-only and safe.
*/
function isSafeToCallWithExec(
args: string[],
cwd?: string,
workspaceRoot?: string,
): boolean {
if (!args || args.length === 0) return false;
const cmd = args[0];
let effectiveWorkspace = workspaceRoot
? path.resolve(workspaceRoot)
: cwd
? path.resolve(cwd)
: process.cwd();
try {
effectiveWorkspace = resolveToRealPath(effectiveWorkspace);
} catch {
// Keep resolved path on failure
}
let effectiveCwd = cwd ? path.resolve(cwd) : effectiveWorkspace;
try {
effectiveCwd = resolveToRealPath(effectiveCwd);
} catch {
// Keep resolved path on failure
}
if (
effectiveCwd !== effectiveWorkspace &&
!isSubpath(effectiveWorkspace, effectiveCwd)
) {
return false;
}
const safeCommands = new Set([
'__read',
'__write',
'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)) {
if (cmd === 'cd') {
let hasPath = false;
for (let i = 1; i < args.length; i++) {
const arg = args[i];
if (arg.startsWith('-')) {
continue;
}
hasPath = true;
if (isPathEscapingWorkspace(arg, effectiveWorkspace, effectiveCwd)) {
return false;
}
}
if (!hasPath) {
if (isPathEscapingWorkspace('~', effectiveWorkspace, effectiveCwd)) {
return false;
}
}
return true;
}
const fileReadingCommands = new Set([
'cat',
'head',
'tail',
'tac',
'nl',
'stat',
'wc',
'cut',
'paste',
'rev',
'uniq',
'numfmt',
'ls',
'__read',
]);
if (fileReadingCommands.has(cmd)) {
let passedDoubleDash = false;
for (let i = 1; i < args.length; i++) {
const arg = args[i];
if (!passedDoubleDash) {
if (arg === '--') {
passedDoubleDash = true;
continue;
}
if (arg === '-') {
continue;
}
if (arg.startsWith('-')) {
if (arg.includes('=')) {
const val = arg.slice(arg.indexOf('=') + 1);
if (
isPathEscapingWorkspace(val, effectiveWorkspace, effectiveCwd)
) {
return false;
}
continue;
}
try {
const stat = fs.lstatSync(path.resolve(effectiveCwd, arg), {
throwIfNoEntry: false,
});
if (!stat) {
continue;
}
} catch {
continue;
}
}
}
if (isPathEscapingWorkspace(arg, effectiveWorkspace, effectiveCwd)) {
return false;
}
}
return true;
}
if (cmd === 'grep') {
let passedDoubleDash = false;
for (let i = 1; i < args.length; i++) {
const arg = args[i];
if (!passedDoubleDash) {
if (arg === '--') {
passedDoubleDash = true;
continue;
}
if (arg === '-') {
continue;
}
if (arg.startsWith('-f') || arg.startsWith('--file')) {
let fileArg: string | undefined;
if (arg.startsWith('--file=')) {
fileArg = arg.slice(7);
} else if (arg === '--file') {
fileArg = args[++i];
} else {
fileArg = arg.length > 2 ? arg.slice(2) : args[++i];
}
if (
fileArg &&
isPathEscapingWorkspace(fileArg, effectiveWorkspace, effectiveCwd)
) {
return false;
}
continue;
}
if (arg.startsWith('-')) {
if (arg.includes('=')) {
const val = arg.slice(arg.indexOf('=') + 1);
if (
isPathEscapingWorkspace(val, effectiveWorkspace, effectiveCwd)
) {
return false;
}
continue;
}
try {
const stat = fs.lstatSync(path.resolve(effectiveCwd, arg), {
throwIfNoEntry: false,
});
if (!stat) {
continue;
}
} catch {
continue;
}
}
}
if (isPathEscapingWorkspace(arg, effectiveWorkspace, effectiveCwd)) {
return false;
}
}
return true;
}
return true;
}
if (cmd === 'base64') {
const unsafeOptions = new Set(['-o', '--output']);
if (
args
.slice(1)
.some(
(arg) =>
unsafeOptions.has(arg) ||
arg.startsWith('--output=') ||
(arg.startsWith('-o') && arg !== '-o'),
)
) {
return false;
}
for (let i = 1; i < args.length; i++) {
const arg = args[i];
if (arg.startsWith('-')) continue;
if (isPathEscapingWorkspace(arg, effectiveWorkspace, effectiveCwd)) {
return false;
}
}
return true;
}
if (cmd === 'find') {
const unsafeOptions = new Set([
'-exec',
'-execdir',
'-ok',
'-okdir',
'-delete',
'-fls',
'-fprint',
'-fprint0',
'-fprintf',
]);
if (args.some((arg) => unsafeOptions.has(arg))) {
return false;
}
for (let i = 1; i < args.length; i++) {
const arg = args[i];
if (arg.startsWith('-')) continue;
if (isPathEscapingWorkspace(arg, effectiveWorkspace, effectiveCwd)) {
return false;
}
}
return true;
}
if (isRipgrepCommand(cmd)) {
if (!isTrustedCommandPath(cmd)) return false;
const unsafeWithArgs = new Set(['--pre', '--hostname-bin']);
const unsafeWithoutArgs = new Set(['--search-zip', '-z']);
for (let i = 1; i < args.length; i++) {
const arg = args[i];
if (unsafeWithoutArgs.has(arg)) return false;
for (const opt of unsafeWithArgs) {
if (arg === opt || arg.startsWith(opt + '=')) return false;
}
}
let passedDoubleDash = false;
for (let i = 1; i < args.length; i++) {
const arg = args[i];
if (!passedDoubleDash) {
if (arg === '--') {
passedDoubleDash = true;
continue;
}
if (arg.startsWith('-f') || arg.startsWith('--file')) {
let fileArg: string | undefined;
if (arg.startsWith('--file=')) {
fileArg = arg.slice(7);
} else if (arg === '--file') {
fileArg = args[++i];
} else {
fileArg = arg.length > 2 ? arg.slice(2) : args[++i];
}
if (
fileArg &&
isPathEscapingWorkspace(fileArg, effectiveWorkspace, effectiveCwd)
) {
return false;
}
continue;
}
if (arg.startsWith('-')) {
if (arg.includes('=')) {
const val = arg.slice(arg.indexOf('=') + 1);
if (
isPathEscapingWorkspace(val, effectiveWorkspace, effectiveCwd)
) {
return false;
}
continue;
}
try {
const stat = fs.lstatSync(path.resolve(effectiveCwd, arg), {
throwIfNoEntry: false,
});
if (!stat) {
continue;
}
} catch {
continue;
}
}
}
if (isPathEscapingWorkspace(arg, effectiveWorkspace, effectiveCwd)) {
return false;
}
}
return true;
}
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])) {
if (args[3]) {
if (
isPathEscapingWorkspace(args[3], effectiveWorkspace, effectiveCwd)
) {
return false;
}
}
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[],
_cwd?: string,
_workspaceRoot?: string,
): boolean {
if (!args || args.length === 0) {
return false;
}
const cmd = args[0];
if (cmd === 'ln') {
const isSymbolic = args.some(
(arg) =>
arg === '-s' ||
arg === '--symbolic' ||
(arg.startsWith('-') && !arg.startsWith('--') && arg.includes('s')),
);
if (isSymbolic) {
return true;
}
}
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 (isRipgrepCommand(cmd)) {
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;
}