/** * Bash intent interceptor - redirects common shell patterns to proper tools. * * When an LLM calls bash with patterns like `grep`, `cat`, `find`, etc., * this interceptor provides helpful error messages directing them to use * the specialized tools instead. */ import { type BashInterceptorRule, DEFAULT_BASH_INTERCEPTOR_RULES } from "../config/settings-schema"; import { extractFlatShellCommandSegments } from "./shell-tokenize"; export interface InterceptionResult { /** If true, the bash command should be blocked */ block: boolean; /** Error message to return instead of executing */ message?: string; /** Suggested tool to use instead */ suggestedTool?: string; } /** * Compile bash interceptor rules into regexes, skipping invalid patterns. */ function compileRules(rules: BashInterceptorRule[]): Array<{ rule: BashInterceptorRule; regex: RegExp }> { const compiled: Array<{ rule: BashInterceptorRule; regex: RegExp }> = []; for (const rule of rules) { const flags = rule.flags ?? ""; try { compiled.push({ rule, regex: new RegExp(rule.pattern, flags) }); } catch { // Skip invalid regex patterns } } return compiled; } /** Finds the end of a shell word, respecting quotes and escapes; returns null for incomplete syntax. */ function skipShellWord(command: string, start: number): number | null { let inSingle = false; let inDouble = false; for (let i = start; i < command.length; i++) { const ch = command[i]; if (inSingle) { if (ch === "'") inSingle = false; continue; } if (inDouble) { if (ch === "\\") { if (i + 1 >= command.length) return null; i++; continue; } if (ch === '"') inDouble = false; continue; } if (ch === "'") { inSingle = true; continue; } if (ch === '"') { inDouble = true; continue; } if (ch === "\\") { if (i + 1 >= command.length) return null; i++; continue; } if (ch === " " || ch === "\t") return i; } return inSingle || inDouble ? null : command.length; } /** Removes leading `NAME=value` assignments without interpreting shell syntax. */ function withoutLeadingEnvironmentAssignments(command: string): string | null { let index = 0; let foundAssignment = false; while (index < command.length) { while (command[index] === " " || command[index] === "\t") index++; const assignmentStart = index; if (!/[A-Za-z_]/.test(command[index] ?? "")) break; let nameEnd = index + 1; while (/[A-Za-z0-9_]/.test(command[nameEnd] ?? "")) nameEnd++; if (command[nameEnd] !== "=") { return foundAssignment ? command.slice(assignmentStart).trimStart() : null; } const wordEnd = skipShellWord(command, nameEnd + 1); if (wordEnd === null) return null; foundAssignment = true; index = wordEnd; if (index === command.length) return null; } if (!foundAssignment) return null; const commandWithoutAssignments = command.slice(index).trimStart(); return commandWithoutAssignments.length > 0 ? commandWithoutAssignments : null; } function interceptionCandidates(command: string): string[] { const candidates = [command.trim()]; for (const segment of extractFlatShellCommandSegments(command)) { // A segment that consumes the previous stage's stdout via `|` reads piped // stdin, which no path-based dedicated tool (read/grep/glob) — nor any // other dedicated tool — can replace, so it is not an interception // candidate. Standalone and first-stage commands still match. if (segment.pipedStdin) continue; candidates.push(segment.text); const withoutAssignments = withoutLeadingEnvironmentAssignments(segment.text); if (withoutAssignments) candidates.push(withoutAssignments); } return candidates; } /** * Check if a bash command should be intercepted. * * @param command The bash command to check * @param availableTools Set of tool names that are available * @returns InterceptionResult indicating if the command should be blocked */ export function checkBashInterception( command: string, availableTools: string[], rules: BashInterceptorRule[] = DEFAULT_BASH_INTERCEPTOR_RULES, originalCommand = command, ): InterceptionResult { const compiled = compileRules(rules); const candidates = interceptionCandidates(command); for (const { rule, regex } of compiled) { // Only block if the suggested tool is actually available if (!availableTools.includes(rule.tool)) { continue; } for (const candidate of candidates) { // A configured global or sticky regex carries state across calls. regex.lastIndex = 0; if (regex.test(candidate)) { return { block: true, message: `Blocked: ${rule.message}\n\nOriginal command: ${originalCommand}`, suggestedTool: rule.tool, }; } } } return { block: false }; }