Files
oh-my-pi/packages/coding-agent/src/tools/bash-interceptor.ts
T
Vincent Huang edb0deebb4 fix(coding-agent): inspect compound Bash commands in interceptor rules
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.
2026-07-31 23:26:43 +12:00

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