mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-13 09:57:24 +00:00
304 lines
8.6 KiB
JavaScript
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;
|
|
}
|