| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303 |
- 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;
- }
|