commandParser.js 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. import { promises as fs } from 'fs';
  2. import path from 'path';
  3. import { execFile } from 'child_process';
  4. import { promisify } from 'util';
  5. import { parse as parseShellCommand } from 'shell-quote';
  6. import { parseFrontmatter } from './frontmatter.js';
  7. const execFileAsync = promisify(execFile);
  8. // Configuration
  9. const MAX_INCLUDE_DEPTH = 3;
  10. const BASH_TIMEOUT = 30000; // 30 seconds
  11. const BASH_COMMAND_ALLOWLIST = [
  12. 'echo',
  13. 'ls',
  14. 'pwd',
  15. 'date',
  16. 'whoami',
  17. 'git',
  18. 'npm',
  19. 'node',
  20. 'cat',
  21. 'grep',
  22. 'find',
  23. 'task-master'
  24. ];
  25. /**
  26. * Parse a markdown command file and extract frontmatter and content
  27. * @param {string} content - Raw markdown content
  28. * @returns {object} Parsed command with data (frontmatter) and content
  29. */
  30. export function parseCommand(content) {
  31. try {
  32. const parsed = parseFrontmatter(content);
  33. return {
  34. data: parsed.data || {},
  35. content: parsed.content || '',
  36. raw: content
  37. };
  38. } catch (error) {
  39. throw new Error(`Failed to parse command: ${error.message}`);
  40. }
  41. }
  42. /**
  43. * Replace argument placeholders in content
  44. * @param {string} content - Content with placeholders
  45. * @param {string|array} args - Arguments to replace (string or array)
  46. * @returns {string} Content with replaced arguments
  47. */
  48. export function replaceArguments(content, args) {
  49. if (!content) return content;
  50. let result = content;
  51. // Convert args to array if it's a string
  52. const argsArray = Array.isArray(args) ? args : (args ? [args] : []);
  53. // Replace $ARGUMENTS with all arguments joined by space
  54. const allArgs = argsArray.join(' ');
  55. result = result.replace(/\$ARGUMENTS/g, allArgs);
  56. // Replace positional arguments $1-$9
  57. for (let i = 1; i <= 9; i++) {
  58. const regex = new RegExp(`\\$${i}`, 'g');
  59. const value = argsArray[i - 1] || '';
  60. result = result.replace(regex, value);
  61. }
  62. return result;
  63. }
  64. /**
  65. * Validate file path to prevent directory traversal
  66. * @param {string} filePath - Path to validate
  67. * @param {string} basePath - Base directory path
  68. * @returns {boolean} True if path is safe
  69. */
  70. export function isPathSafe(filePath, basePath) {
  71. const resolvedPath = path.resolve(basePath, filePath);
  72. const resolvedBase = path.resolve(basePath);
  73. const relative = path.relative(resolvedBase, resolvedPath);
  74. return (
  75. relative !== '' &&
  76. !relative.startsWith('..') &&
  77. !path.isAbsolute(relative)
  78. );
  79. }
  80. /**
  81. * Process file includes in content (@filename syntax)
  82. * @param {string} content - Content with @filename includes
  83. * @param {string} basePath - Base directory for resolving file paths
  84. * @param {number} depth - Current recursion depth
  85. * @returns {Promise<string>} Content with includes resolved
  86. */
  87. export async function processFileIncludes(content, basePath, depth = 0) {
  88. if (!content) return content;
  89. // Prevent infinite recursion
  90. if (depth >= MAX_INCLUDE_DEPTH) {
  91. throw new Error(`Maximum include depth (${MAX_INCLUDE_DEPTH}) exceeded`);
  92. }
  93. // Match @filename patterns (at start of line or after whitespace)
  94. const includePattern = /(?:^|\s)@([^\s]+)/gm;
  95. const matches = [...content.matchAll(includePattern)];
  96. if (matches.length === 0) {
  97. return content;
  98. }
  99. let result = content;
  100. for (const match of matches) {
  101. const fullMatch = match[0];
  102. const filename = match[1];
  103. // Security: prevent directory traversal
  104. if (!isPathSafe(filename, basePath)) {
  105. throw new Error(`Invalid file path (directory traversal detected): ${filename}`);
  106. }
  107. try {
  108. const filePath = path.resolve(basePath, filename);
  109. const fileContent = await fs.readFile(filePath, 'utf-8');
  110. // Recursively process includes in the included file
  111. const processedContent = await processFileIncludes(fileContent, basePath, depth + 1);
  112. // Replace the @filename with the file content
  113. result = result.replace(fullMatch, fullMatch.startsWith(' ') ? ' ' + processedContent : processedContent);
  114. } catch (error) {
  115. if (error.code === 'ENOENT') {
  116. throw new Error(`File not found: ${filename}`);
  117. }
  118. throw error;
  119. }
  120. }
  121. return result;
  122. }
  123. /**
  124. * Validate that a command and its arguments are safe
  125. * @param {string} commandString - Command string to validate
  126. * @returns {{ allowed: boolean, command: string, args: string[], error?: string }} Validation result
  127. */
  128. export function validateCommand(commandString) {
  129. const trimmedCommand = commandString.trim();
  130. if (!trimmedCommand) {
  131. return { allowed: false, command: '', args: [], error: 'Empty command' };
  132. }
  133. // Parse the command using shell-quote to handle quotes properly
  134. const parsed = parseShellCommand(trimmedCommand);
  135. // Check for shell operators or control structures
  136. const hasOperators = parsed.some(token =>
  137. typeof token === 'object' && token.op
  138. );
  139. if (hasOperators) {
  140. return {
  141. allowed: false,
  142. command: '',
  143. args: [],
  144. error: 'Shell operators (&&, ||, |, ;, etc.) are not allowed'
  145. };
  146. }
  147. // Extract command and args (all should be strings after validation)
  148. const tokens = parsed.filter(token => typeof token === 'string');
  149. if (tokens.length === 0) {
  150. return { allowed: false, command: '', args: [], error: 'No valid command found' };
  151. }
  152. const [command, ...args] = tokens;
  153. // Extract just the command name (remove path if present)
  154. const commandName = path.basename(command);
  155. // Check if command exactly matches allowlist (no prefix matching)
  156. const isAllowed = BASH_COMMAND_ALLOWLIST.includes(commandName);
  157. if (!isAllowed) {
  158. return {
  159. allowed: false,
  160. command: commandName,
  161. args,
  162. error: `Command '${commandName}' is not in the allowlist`
  163. };
  164. }
  165. // Validate arguments don't contain dangerous metacharacters
  166. const dangerousPattern = /[;&|`$()<>{}[\]\\]/;
  167. for (const arg of args) {
  168. if (dangerousPattern.test(arg)) {
  169. return {
  170. allowed: false,
  171. command: commandName,
  172. args,
  173. error: `Argument contains dangerous characters: ${arg}`
  174. };
  175. }
  176. }
  177. return { allowed: true, command: commandName, args };
  178. }
  179. /**
  180. * Backward compatibility: Check if command is allowed (deprecated)
  181. * @deprecated Use validateCommand() instead for better security
  182. * @param {string} command - Command to validate
  183. * @returns {boolean} True if command is allowed
  184. */
  185. export function isBashCommandAllowed(command) {
  186. const result = validateCommand(command);
  187. return result.allowed;
  188. }
  189. /**
  190. * Sanitize bash command output
  191. * @param {string} output - Raw command output
  192. * @returns {string} Sanitized output
  193. */
  194. export function sanitizeOutput(output) {
  195. if (!output) return '';
  196. // Remove control characters except \t, \n, \r
  197. return [...output]
  198. .filter(ch => {
  199. const code = ch.charCodeAt(0);
  200. return code === 9 // \t
  201. || code === 10 // \n
  202. || code === 13 // \r
  203. || (code >= 32 && code !== 127);
  204. })
  205. .join('');
  206. }
  207. /**
  208. * Process bash commands in content (!command syntax)
  209. * @param {string} content - Content with !command syntax
  210. * @param {object} options - Options for bash execution
  211. * @returns {Promise<string>} Content with bash commands executed and replaced
  212. */
  213. export async function processBashCommands(content, options = {}) {
  214. if (!content) return content;
  215. const { cwd = process.cwd(), timeout = BASH_TIMEOUT } = options;
  216. // Match !command patterns (at start of line or after whitespace)
  217. const commandPattern = /(?:^|\n)!(.+?)(?=\n|$)/g;
  218. const matches = [...content.matchAll(commandPattern)];
  219. if (matches.length === 0) {
  220. return content;
  221. }
  222. let result = content;
  223. for (const match of matches) {
  224. const fullMatch = match[0];
  225. const commandString = match[1].trim();
  226. // Security: validate command and parse args
  227. const validation = validateCommand(commandString);
  228. if (!validation.allowed) {
  229. throw new Error(`Command not allowed: ${commandString} - ${validation.error}`);
  230. }
  231. try {
  232. // Execute without shell using execFile with parsed args
  233. const { stdout, stderr } = await execFileAsync(
  234. validation.command,
  235. validation.args,
  236. {
  237. cwd,
  238. timeout,
  239. maxBuffer: 1024 * 1024, // 1MB max output
  240. shell: false, // IMPORTANT: No shell interpretation
  241. env: { ...process.env, PATH: process.env.PATH } // Inherit PATH for finding commands
  242. }
  243. );
  244. const output = sanitizeOutput(stdout || stderr || '');
  245. // Replace the !command with the output
  246. result = result.replace(fullMatch, fullMatch.startsWith('\n') ? '\n' + output : output);
  247. } catch (error) {
  248. if (error.killed) {
  249. throw new Error(`Command timeout: ${commandString}`);
  250. }
  251. throw new Error(`Command failed: ${commandString} - ${error.message}`);
  252. }
  253. }
  254. return result;
  255. }