/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */
import os from 'node:os';
import { quote } from 'shell-quote';
import { doesToolInvocationMatch } from './tool-utils.js';
import { spawn, spawnSync, } from 'node:child_process';
import { Language, Parser } from 'web-tree-sitter';
import { loadWasmBinary } from './fileUtils.js';
export const SHELL_TOOL_NAMES = ['run_shell_command', 'ShellTool'];
let bashLanguage = null;
let treeSitterInitialization = null;
let treeSitterInitializationError = null;
class ShellParserInitializationError extends Error {
    constructor(cause) {
        super(`Failed to initialize bash parser: ${cause.message}`, { cause });
        this.name = 'ShellParserInitializationError';
    }
}
function toError(value) {
    if (value instanceof Error) {
        return value;
    }
    if (typeof value === 'string') {
        return new Error(value);
    }
    return new Error('Unknown tree-sitter initialization error', {
        cause: value,
    });
}
async function loadBashLanguage() {
    try {
        treeSitterInitializationError = null;
        const [treeSitterBinary, bashBinary] = await Promise.all([
            loadWasmBinary(() => 
            // eslint-disable-next-line @typescript-eslint/ban-ts-comment
            // @ts-ignore resolved by esbuild-plugin-wasm during bundling
            import('web-tree-sitter/tree-sitter.wasm?binary'), 'web-tree-sitter/tree-sitter.wasm'),
            loadWasmBinary(() => 
            // eslint-disable-next-line @typescript-eslint/ban-ts-comment
            // @ts-ignore resolved by esbuild-plugin-wasm during bundling
            import('tree-sitter-bash/tree-sitter-bash.wasm?binary'), 'tree-sitter-bash/tree-sitter-bash.wasm'),
        ]);
        await Parser.init({ wasmBinary: treeSitterBinary });
        bashLanguage = await Language.load(bashBinary);
    }
    catch (error) {
        bashLanguage = null;
        const normalized = toError(error);
        const initializationError = normalized instanceof ShellParserInitializationError
            ? normalized
            : new ShellParserInitializationError(normalized);
        treeSitterInitializationError = initializationError;
        throw initializationError;
    }
}
export async function initializeShellParsers() {
    if (!treeSitterInitialization) {
        treeSitterInitialization = loadBashLanguage().catch((error) => {
            treeSitterInitialization = null;
            throw error;
        });
    }
    await treeSitterInitialization;
}
const POWERSHELL_COMMAND_ENV = '__GCLI_POWERSHELL_COMMAND__';
// Encode the parser script as UTF-16LE base64 so we can pass it via PowerShell's -EncodedCommand flag;
// this avoids brittle quoting/escaping when spawning PowerShell and ensures the script is received byte-for-byte.
const POWERSHELL_PARSER_SCRIPT = Buffer.from(`
$ErrorActionPreference = 'Stop'
$commandText = $env:${POWERSHELL_COMMAND_ENV}
if ([string]::IsNullOrEmpty($commandText)) {
  Write-Output '{"success":false}'
  exit 0
}
$tokens = $null
$errors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseInput($commandText, [ref]$tokens, [ref]$errors)
if ($errors -and $errors.Count -gt 0) {
  Write-Output '{"success":false}'
  exit 0
}
$commandAsts = $ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.CommandAst] }, $true)
$commandObjects = @()
foreach ($commandAst in $commandAsts) {
  $name = $commandAst.GetCommandName()
  if ([string]::IsNullOrWhiteSpace($name)) {
    continue
  }
  $commandObjects += [PSCustomObject]@{
    name = $name
    text = $commandAst.Extent.Text.Trim()
  }
}
[PSCustomObject]@{
  success = $true
  commands = $commandObjects
} | ConvertTo-Json -Compress
`, 'utf16le').toString('base64');
function createParser() {
    if (!bashLanguage) {
        if (treeSitterInitializationError) {
            throw treeSitterInitializationError;
        }
        return null;
    }
    try {
        const parser = new Parser();
        parser.setLanguage(bashLanguage);
        return parser;
    }
    catch {
        return null;
    }
}
function parseCommandTree(command) {
    const parser = createParser();
    if (!parser || !command.trim()) {
        return null;
    }
    try {
        return parser.parse(command);
    }
    catch {
        return null;
    }
}
function normalizeCommandName(raw) {
    if (raw.length >= 2) {
        const first = raw[0];
        const last = raw[raw.length - 1];
        if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
            return raw.slice(1, -1);
        }
    }
    const trimmed = raw.trim();
    if (!trimmed) {
        return trimmed;
    }
    return trimmed.split(/[\\/]/).pop() ?? trimmed;
}
function extractNameFromNode(node) {
    switch (node.type) {
        case 'command': {
            const nameNode = node.childForFieldName('name');
            if (!nameNode) {
                return null;
            }
            return normalizeCommandName(nameNode.text);
        }
        case 'declaration_command':
        case 'unset_command':
        case 'test_command': {
            const firstChild = node.child(0);
            if (!firstChild) {
                return null;
            }
            return normalizeCommandName(firstChild.text);
        }
        default:
            return null;
    }
}
function collectCommandDetails(root, source) {
    const stack = [root];
    const details = [];
    while (stack.length > 0) {
        const current = stack.pop();
        if (!current) {
            continue;
        }
        const commandName = extractNameFromNode(current);
        if (commandName) {
            details.push({
                name: commandName,
                text: source.slice(current.startIndex, current.endIndex).trim(),
            });
        }
        for (let i = current.namedChildCount - 1; i >= 0; i -= 1) {
            const child = current.namedChild(i);
            if (child) {
                stack.push(child);
            }
        }
    }
    return details;
}
function parseBashCommandDetails(command) {
    if (treeSitterInitializationError) {
        throw treeSitterInitializationError;
    }
    if (!bashLanguage) {
        initializeShellParsers().catch(() => {
            // The failure path is surfaced via treeSitterInitializationError.
        });
        return null;
    }
    const tree = parseCommandTree(command);
    if (!tree) {
        return null;
    }
    const details = collectCommandDetails(tree.rootNode, command);
    return {
        details,
        hasError: tree.rootNode.hasError || details.length === 0,
    };
}
function parsePowerShellCommandDetails(command, executable) {
    const trimmed = command.trim();
    if (!trimmed) {
        return {
            details: [],
            hasError: true,
        };
    }
    try {
        const result = spawnSync(executable, [
            '-NoLogo',
            '-NoProfile',
            '-NonInteractive',
            '-EncodedCommand',
            POWERSHELL_PARSER_SCRIPT,
        ], {
            env: {
                ...process.env,
                [POWERSHELL_COMMAND_ENV]: command,
            },
            encoding: 'utf-8',
        });
        if (result.error || result.status !== 0) {
            return null;
        }
        const output = (result.stdout ?? '').toString().trim();
        if (!output) {
            return { details: [], hasError: true };
        }
        let parsed = null;
        try {
            parsed = JSON.parse(output);
        }
        catch {
            return { details: [], hasError: true };
        }
        if (!parsed?.success) {
            return { details: [], hasError: true };
        }
        const details = (parsed.commands ?? [])
            .map((commandDetail) => {
            if (!commandDetail || typeof commandDetail.name !== 'string') {
                return null;
            }
            const name = normalizeCommandName(commandDetail.name);
            const text = typeof commandDetail.text === 'string'
                ? commandDetail.text.trim()
                : command;
            return {
                name,
                text,
            };
        })
            .filter((detail) => detail !== null);
        return {
            details,
            hasError: details.length === 0,
        };
    }
    catch {
        return null;
    }
}
function parseCommandDetails(command) {
    const configuration = getShellConfiguration();
    if (configuration.shell === 'powershell') {
        return parsePowerShellCommandDetails(command, configuration.executable);
    }
    if (configuration.shell === 'bash') {
        return parseBashCommandDetails(command);
    }
    return null;
}
/**
 * Determines the appropriate shell configuration for the current platform.
 *
 * This ensures we can execute command strings predictably and securely across platforms
 * using the `spawn(executable, [...argsPrefix, commandString], { shell: false })` pattern.
 *
 * @returns The ShellConfiguration for the current environment.
 */
export function getShellConfiguration() {
    if (isWindows()) {
        const comSpec = process.env['ComSpec'];
        if (comSpec) {
            const executable = comSpec.toLowerCase();
            if (executable.endsWith('powershell.exe') ||
                executable.endsWith('pwsh.exe')) {
                return {
                    executable: comSpec,
                    argsPrefix: ['-NoProfile', '-Command'],
                    shell: 'powershell',
                };
            }
        }
        // Default to PowerShell for all other Windows configurations.
        return {
            executable: 'powershell.exe',
            argsPrefix: ['-NoProfile', '-Command'],
            shell: 'powershell',
        };
    }
    // Unix-like systems (Linux, macOS)
    return { executable: 'bash', argsPrefix: ['-c'], shell: 'bash' };
}
/**
 * Export the platform detection constant for use in process management (e.g., killing processes).
 */
export const isWindows = () => os.platform() === 'win32';
/**
 * Escapes a string so that it can be safely used as a single argument
 * in a shell command, preventing command injection.
 *
 * @param arg The argument string to escape.
 * @param shell The type of shell the argument is for.
 * @returns The shell-escaped string.
 */
export function escapeShellArg(arg, shell) {
    if (!arg) {
        return '';
    }
    switch (shell) {
        case 'powershell':
            // For PowerShell, wrap in single quotes and escape internal single quotes by doubling them.
            return `'${arg.replace(/'/g, "''")}'`;
        case 'cmd':
            // Simple Windows escaping for cmd.exe: wrap in double quotes and escape inner double quotes.
            return `"${arg.replace(/"/g, '""')}"`;
        case 'bash':
        default:
            // POSIX shell escaping using shell-quote.
            return quote([arg]);
    }
}
/**
 * Splits a shell command into a list of individual commands, respecting quotes.
 * This is used to separate chained commands (e.g., using &&, ||, ;).
 * @param command The shell command string to parse
 * @returns An array of individual command strings
 */
export function splitCommands(command) {
    const parsed = parseCommandDetails(command);
    if (!parsed || parsed.hasError) {
        return [];
    }
    return parsed.details.map((detail) => detail.text).filter(Boolean);
}
/**
 * Extracts the root command from a given shell command string.
 * This is used to identify the base command for permission checks.
 * @param command The shell command string to parse
 * @returns The root command name, or undefined if it cannot be determined
 * @example getCommandRoot("ls -la /tmp") returns "ls"
 * @example getCommandRoot("git status && npm test") returns "git"
 */
export function getCommandRoot(command) {
    const parsed = parseCommandDetails(command);
    if (!parsed || parsed.hasError || parsed.details.length === 0) {
        return undefined;
    }
    return parsed.details[0]?.name;
}
export function getCommandRoots(command) {
    if (!command) {
        return [];
    }
    const parsed = parseCommandDetails(command);
    if (!parsed || parsed.hasError) {
        return [];
    }
    return parsed.details.map((detail) => detail.name).filter(Boolean);
}
export function stripShellWrapper(command) {
    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;
    const match = command.match(pattern);
    if (match) {
        let newCommand = command.substring(match[0].length).trim();
        if ((newCommand.startsWith('"') && newCommand.endsWith('"')) ||
            (newCommand.startsWith("'") && newCommand.endsWith("'"))) {
            newCommand = newCommand.substring(1, newCommand.length - 1);
        }
        return newCommand;
    }
    return command.trim();
}
/**
 * Detects command substitution patterns in a shell command, following bash quoting rules:
 * - Single quotes ('): Everything literal, no substitution possible
 * - Double quotes ("): Command substitution with $() and backticks unless escaped with \
 * - No quotes: Command substitution with $(), <(), and backticks
 * @param command The shell command string to check
 * @returns true if command substitution would be executed by bash
 */
/**
 * Checks a shell command against security policies and allowlists.
 *
 * This function operates in one of two modes depending on the presence of
 * the `sessionAllowlist` parameter:
 *
 * 1.  **"Default Deny" Mode (sessionAllowlist is provided):** This is the
 *     strictest mode, used for user-defined scripts like custom commands.
 *     A command is only permitted if it is found on the global `coreTools`
 *     allowlist OR the provided `sessionAllowlist`. It must not be on the
 *     global `excludeTools` blocklist.
 *
 * 2.  **"Default Allow" Mode (sessionAllowlist is NOT provided):** This mode
 *     is used for direct tool invocations (e.g., by the model). If a strict
 *     global `coreTools` allowlist exists, commands must be on it. Otherwise,
 *     any command is permitted as long as it is not on the `excludeTools`
 *     blocklist.
 *
 * @param command The shell command string to validate.
 * @param config The application configuration.
 * @param sessionAllowlist A session-level list of approved commands. Its
 *   presence activates "Default Deny" mode.
 * @returns An object detailing which commands are not allowed.
 */
export function checkCommandPermissions(command, config, sessionAllowlist) {
    const parseResult = parseCommandDetails(command);
    if (!parseResult || parseResult.hasError) {
        return {
            allAllowed: false,
            disallowedCommands: [command],
            blockReason: 'Command rejected because it could not be parsed safely',
            isHardDenial: true,
        };
    }
    const normalize = (cmd) => cmd.trim().replace(/\s+/g, ' ');
    const commandsToValidate = parseResult.details
        .map((detail) => normalize(detail.text))
        .filter(Boolean);
    const invocation = {
        params: { command: '' },
    };
    // 1. Blocklist Check (Highest Priority)
    const excludeTools = config.getExcludeTools() || [];
    const isWildcardBlocked = SHELL_TOOL_NAMES.some((name) => excludeTools.includes(name));
    if (isWildcardBlocked) {
        return {
            allAllowed: false,
            disallowedCommands: commandsToValidate,
            blockReason: 'Shell tool is globally disabled in configuration',
            isHardDenial: true,
        };
    }
    for (const cmd of commandsToValidate) {
        invocation.params['command'] = cmd;
        if (doesToolInvocationMatch('run_shell_command', invocation, excludeTools)) {
            return {
                allAllowed: false,
                disallowedCommands: [cmd],
                blockReason: `Command '${cmd}' is blocked by configuration`,
                isHardDenial: true,
            };
        }
    }
    const coreTools = config.getCoreTools() || [];
    const isWildcardAllowed = SHELL_TOOL_NAMES.some((name) => coreTools.includes(name));
    // If there's a global wildcard, all commands are allowed at this point
    // because they have already passed the blocklist check.
    if (isWildcardAllowed) {
        return { allAllowed: true, disallowedCommands: [] };
    }
    const disallowedCommands = [];
    if (sessionAllowlist) {
        // "DEFAULT DENY" MODE: A session allowlist is provided.
        // All commands must be in either the session or global allowlist.
        const normalizedSessionAllowlist = new Set([...sessionAllowlist].flatMap((cmd) => SHELL_TOOL_NAMES.map((name) => `${name}(${cmd})`)));
        for (const cmd of commandsToValidate) {
            invocation.params['command'] = cmd;
            const isSessionAllowed = doesToolInvocationMatch('run_shell_command', invocation, [...normalizedSessionAllowlist]);
            if (isSessionAllowed)
                continue;
            const isGloballyAllowed = doesToolInvocationMatch('run_shell_command', invocation, coreTools);
            if (isGloballyAllowed)
                continue;
            disallowedCommands.push(cmd);
        }
        if (disallowedCommands.length > 0) {
            return {
                allAllowed: false,
                disallowedCommands,
                blockReason: `Command(s) not on the global or session allowlist. Disallowed commands: ${disallowedCommands
                    .map((c) => JSON.stringify(c))
                    .join(', ')}`,
                isHardDenial: false, // This is a soft denial; confirmation is possible.
            };
        }
    }
    else {
        // "DEFAULT ALLOW" MODE: No session allowlist.
        const hasSpecificAllowedCommands = coreTools.filter((tool) => SHELL_TOOL_NAMES.some((name) => tool.startsWith(`${name}(`))).length > 0;
        if (hasSpecificAllowedCommands) {
            for (const cmd of commandsToValidate) {
                invocation.params['command'] = cmd;
                const isGloballyAllowed = doesToolInvocationMatch('run_shell_command', invocation, coreTools);
                if (!isGloballyAllowed) {
                    disallowedCommands.push(cmd);
                }
            }
            if (disallowedCommands.length > 0) {
                return {
                    allAllowed: false,
                    disallowedCommands,
                    blockReason: `Command(s) not in the allowed commands list. Disallowed commands: ${disallowedCommands
                        .map((c) => JSON.stringify(c))
                        .join(', ')}`,
                    isHardDenial: false, // This is a soft denial.
                };
            }
        }
        // If no specific global allowlist exists, and it passed the blocklist,
        // the command is allowed by default.
    }
    // If all checks for the current mode pass, the command is allowed.
    return { allAllowed: true, disallowedCommands: [] };
}
/**
 * Determines whether a given shell command is allowed to execute based on
 * the tool's configuration including allowlists and blocklists.
 *
 * This function operates in "default allow" mode. It is a wrapper around
 * `checkCommandPermissions`.
 *
 * @param command The shell command string to validate.
 * @param config The application configuration.
 * @returns An object with 'allowed' boolean and optional 'reason' string if not allowed.
 */
export const spawnAsync = (command, args, options) => new Promise((resolve, reject) => {
    const child = spawn(command, args, options);
    let stdout = '';
    let stderr = '';
    child.stdout.on('data', (data) => {
        stdout += data.toString();
    });
    child.stderr.on('data', (data) => {
        stderr += data.toString();
    });
    child.on('close', (code) => {
        if (code === 0) {
            resolve({ stdout, stderr });
        }
        else {
            reject(new Error(`Command failed with exit code ${code}:\n${stderr}`));
        }
    });
    child.on('error', (err) => {
        reject(err);
    });
});
export function isCommandAllowed(command, config) {
    // By not providing a sessionAllowlist, we invoke "default allow" behavior.
    const { allAllowed, blockReason } = checkCommandPermissions(command, config);
    if (allAllowed) {
        return { allowed: true };
    }
    return { allowed: false, reason: blockReason };
}
/**
 * Determines whether a shell invocation should be auto-approved based on an allowlist.
 *
 * This reuses the same parsing logic as command-permission enforcement so that
 * chained commands must be individually covered by the allowlist.
 *
 * @param invocation The shell tool invocation being evaluated.
 * @param allowedPatterns The configured allowlist patterns (e.g. `run_shell_command(git)`).
 * @returns True if every parsed command segment is allowed by the patterns; false otherwise.
 */
export function isShellInvocationAllowlisted(invocation, allowedPatterns) {
    if (!allowedPatterns.length) {
        return false;
    }
    const hasShellWildcard = allowedPatterns.some((pattern) => SHELL_TOOL_NAMES.includes(pattern));
    const hasShellSpecificPattern = allowedPatterns.some((pattern) => SHELL_TOOL_NAMES.some((name) => pattern.startsWith(`${name}(`)));
    if (!hasShellWildcard && !hasShellSpecificPattern) {
        return false;
    }
    if (hasShellWildcard) {
        return true;
    }
    if (!('params' in invocation) ||
        typeof invocation.params !== 'object' ||
        invocation.params === null ||
        !('command' in invocation.params)) {
        return false;
    }
    const commandValue = invocation.params.command;
    if (typeof commandValue !== 'string' || !commandValue.trim()) {
        return false;
    }
    const command = commandValue.trim();
    const parseResult = parseCommandDetails(command);
    if (!parseResult || parseResult.hasError) {
        return false;
    }
    const normalize = (cmd) => cmd.trim().replace(/\s+/g, ' ');
    const commandsToValidate = parseResult.details
        .map((detail) => normalize(detail.text))
        .filter(Boolean);
    if (commandsToValidate.length === 0) {
        return false;
    }
    return commandsToValidate.every((commandSegment) => doesToolInvocationMatch(SHELL_TOOL_NAMES[0], { params: { command: commandSegment } }, allowedPatterns));
}
//# sourceMappingURL=shell-utils.js.map