edb0deebb4
Match interceptor regexes against conservative, raw shell command segments in addition to the complete command, so anchored rules can detect commands after &&, ||, ;, |, &, and newlines without treating quoted or escaped text as commands. Add extractFlatShellCommandSegments() to preserve source text for user-configured regexes, unlike the token-based approval matcher. Add skipShellWord() and environment-assignment stripping so rules can match commands prefixed with NAME=value assignments. Preserve the original command in interception errors after extracting a leading cd command.
145 lines
4.4 KiB
TypeScript
145 lines
4.4 KiB
TypeScript
/**
|
|
* 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()];
|
|
const segments = extractFlatShellCommandSegments(command);
|
|
candidates.push(...segments.map(segment => segment.trim()));
|
|
for (const segment of segments) {
|
|
const withoutAssignments = withoutLeadingEnvironmentAssignments(segment);
|
|
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 };
|
|
}
|