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);
const MAX_INCLUDE_DEPTH = 3;
const BASH_TIMEOUT = 30000;
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;
const argsArray = Array.isArray(args) ? args : (args ? [args] : []);
const allArgs = argsArray.join(' ');
result = result.replace(/\$ARGUMENTS/g, allArgs);
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;
if (depth >= MAX_INCLUDE_DEPTH) {
throw new Error(`Maximum include depth (${MAX_INCLUDE_DEPTH}) exceeded`);
}
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];
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');
const processedContent = await processFileIncludes(fileContent, basePath, depth + 1);
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' };
}
const parsed = parseShellCommand(trimmedCommand);
const hasOperators = parsed.some(token =>
typeof token === 'object' && token.op
);
if (hasOperators) {
return {
allowed: false,
command: '',
args: [],
error: 'Shell operators (&&, ||, |, ;, etc.) are not allowed'
};
}
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;
const commandName = path.basename(command);
const isAllowed = BASH_COMMAND_ALLOWLIST.includes(commandName);
if (!isAllowed) {
return {
allowed: false,
command: commandName,
args,
error: `Command '${commandName}' is not in the allowlist`
};
}
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 '';
return [...output]
.filter(ch => {
const code = ch.charCodeAt(0);
return code === 9
|| code === 10
|| code === 13
|| (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;
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();
const validation = validateCommand(commandString);
if (!validation.allowed) {
throw new Error(`Command not allowed: ${commandString} - ${validation.error}`);
}
try {
const { stdout, stderr } = await execFileAsync(
validation.command,
validation.args,
{
cwd,
timeout,
maxBuffer: 1024 * 1024,
shell: false,
env: { ...process.env, PATH: process.env.PATH }
}
);
const output = sanitizeOutput(stdout || stderr || '');
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;
}