Files
claudecodeui/server/utils/commandParser.js

304 lines
8.6 KiB
JavaScript

import { promises as fs } from 'fs';
import path from 'path';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { parse as parseShellCommand } from 'shell-quote';
import { parseFrontmatter } from './frontmatter.js';
const execFileAsync = promisify(execFile);
// Configuration
const MAX_INCLUDE_DEPTH = 3;
const BASH_TIMEOUT = 30000; // 30 seconds
const BASH_COMMAND_ALLOWLIST = [
'echo',
'ls',
'pwd',
'date',
'whoami',
'git',
'npm',
'node',
'cat',
'grep',
'find',
'task-master'
];
/**
* Parse a markdown command file and extract frontmatter and content
* @param {string} content - Raw markdown content
* @returns {object} Parsed command with data (frontmatter) and content
*/
export function parseCommand(content) {
try {
const parsed = parseFrontmatter(content);
return {
data: parsed.data || {},
content: parsed.content || '',
raw: content
};
} catch (error) {
throw new Error(`Failed to parse command: ${error.message}`);
}
}
/**
* Replace argument placeholders in content
* @param {string} content - Content with placeholders
* @param {string|array} args - Arguments to replace (string or array)
* @returns {string} Content with replaced arguments
*/
export function replaceArguments(content, args) {
if (!content) return content;
let result = content;
// Convert args to array if it's a string
const argsArray = Array.isArray(args) ? args : (args ? [args] : []);
// Replace $ARGUMENTS with all arguments joined by space
const allArgs = argsArray.join(' ');
result = result.replace(/\$ARGUMENTS/g, allArgs);
// Replace positional arguments $1-$9
for (let i = 1; i <= 9; i++) {
const regex = new RegExp(`\\$${i}`, 'g');
const value = argsArray[i - 1] || '';
result = result.replace(regex, value);
}
return result;
}
/**
* Validate file path to prevent directory traversal
* @param {string} filePath - Path to validate
* @param {string} basePath - Base directory path
* @returns {boolean} True if path is safe
*/
export function isPathSafe(filePath, basePath) {
const resolvedPath = path.resolve(basePath, filePath);
const resolvedBase = path.resolve(basePath);
const relative = path.relative(resolvedBase, resolvedPath);
return (
relative !== '' &&
!relative.startsWith('..') &&
!path.isAbsolute(relative)
);
}
/**
* Process file includes in content (@filename syntax)
* @param {string} content - Content with @filename includes
* @param {string} basePath - Base directory for resolving file paths
* @param {number} depth - Current recursion depth
* @returns {Promise<string>} Content with includes resolved
*/
export async function processFileIncludes(content, basePath, depth = 0) {
if (!content) return content;
// Prevent infinite recursion
if (depth >= MAX_INCLUDE_DEPTH) {
throw new Error(`Maximum include depth (${MAX_INCLUDE_DEPTH}) exceeded`);
}
// Match @filename patterns (at start of line or after whitespace)
const includePattern = /(?:^|\s)@([^\s]+)/gm;
const matches = [...content.matchAll(includePattern)];
if (matches.length === 0) {
return content;
}
let result = content;
for (const match of matches) {
const fullMatch = match[0];
const filename = match[1];
// Security: prevent directory traversal
if (!isPathSafe(filename, basePath)) {
throw new Error(`Invalid file path (directory traversal detected): ${filename}`);
}
try {
const filePath = path.resolve(basePath, filename);
const fileContent = await fs.readFile(filePath, 'utf-8');
// Recursively process includes in the included file
const processedContent = await processFileIncludes(fileContent, basePath, depth + 1);
// Replace the @filename with the file content
result = result.replace(fullMatch, fullMatch.startsWith(' ') ? ' ' + processedContent : processedContent);
} catch (error) {
if (error.code === 'ENOENT') {
throw new Error(`File not found: ${filename}`);
}
throw error;
}
}
return result;
}
/**
* Validate that a command and its arguments are safe
* @param {string} commandString - Command string to validate
* @returns {{ allowed: boolean, command: string, args: string[], error?: string }} Validation result
*/
export function validateCommand(commandString) {
const trimmedCommand = commandString.trim();
if (!trimmedCommand) {
return { allowed: false, command: '', args: [], error: 'Empty command' };
}
// Parse the command using shell-quote to handle quotes properly
const parsed = parseShellCommand(trimmedCommand);
// Check for shell operators or control structures
const hasOperators = parsed.some(token =>
typeof token === 'object' && token.op
);
if (hasOperators) {
return {
allowed: false,
command: '',
args: [],
error: 'Shell operators (&&, ||, |, ;, etc.) are not allowed'
};
}
// Extract command and args (all should be strings after validation)
const tokens = parsed.filter(token => typeof token === 'string');
if (tokens.length === 0) {
return { allowed: false, command: '', args: [], error: 'No valid command found' };
}
const [command, ...args] = tokens;
// Extract just the command name (remove path if present)
const commandName = path.basename(command);
// Check if command exactly matches allowlist (no prefix matching)
const isAllowed = BASH_COMMAND_ALLOWLIST.includes(commandName);
if (!isAllowed) {
return {
allowed: false,
command: commandName,
args,
error: `Command '${commandName}' is not in the allowlist`
};
}
// Validate arguments don't contain dangerous metacharacters
const dangerousPattern = /[;&|`$()<>{}[\]\\]/;
for (const arg of args) {
if (dangerousPattern.test(arg)) {
return {
allowed: false,
command: commandName,
args,
error: `Argument contains dangerous characters: ${arg}`
};
}
}
return { allowed: true, command: commandName, args };
}
/**
* Backward compatibility: Check if command is allowed (deprecated)
* @deprecated Use validateCommand() instead for better security
* @param {string} command - Command to validate
* @returns {boolean} True if command is allowed
*/
export function isBashCommandAllowed(command) {
const result = validateCommand(command);
return result.allowed;
}
/**
* Sanitize bash command output
* @param {string} output - Raw command output
* @returns {string} Sanitized output
*/
export function sanitizeOutput(output) {
if (!output) return '';
// Remove control characters except \t, \n, \r
return [...output]
.filter(ch => {
const code = ch.charCodeAt(0);
return code === 9 // \t
|| code === 10 // \n
|| code === 13 // \r
|| (code >= 32 && code !== 127);
})
.join('');
}
/**
* Process bash commands in content (!command syntax)
* @param {string} content - Content with !command syntax
* @param {object} options - Options for bash execution
* @returns {Promise<string>} Content with bash commands executed and replaced
*/
export async function processBashCommands(content, options = {}) {
if (!content) return content;
const { cwd = process.cwd(), timeout = BASH_TIMEOUT } = options;
// Match !command patterns (at start of line or after whitespace)
const commandPattern = /(?:^|\n)!(.+?)(?=\n|$)/g;
const matches = [...content.matchAll(commandPattern)];
if (matches.length === 0) {
return content;
}
let result = content;
for (const match of matches) {
const fullMatch = match[0];
const commandString = match[1].trim();
// Security: validate command and parse args
const validation = validateCommand(commandString);
if (!validation.allowed) {
throw new Error(`Command not allowed: ${commandString} - ${validation.error}`);
}
try {
// Execute without shell using execFile with parsed args
const { stdout, stderr } = await execFileAsync(
validation.command,
validation.args,
{
cwd,
timeout,
maxBuffer: 1024 * 1024, // 1MB max output
shell: false, // IMPORTANT: No shell interpretation
env: { ...process.env, PATH: process.env.PATH } // Inherit PATH for finding commands
}
);
const output = sanitizeOutput(stdout || stderr || '');
// Replace the !command with the output
result = result.replace(fullMatch, fullMatch.startsWith('\n') ? '\n' + output : output);
} catch (error) {
if (error.killed) {
throw new Error(`Command timeout: ${commandString}`);
}
throw new Error(`Command failed: ${commandString} - ${error.message}`);
}
}
return result;
}