Files
oh-my-pi/packages/coding-agent/src/tools/bash-interceptor.ts
T
roboomp cbfbcd865e fix(tool): exempt piped-stdin stages from bash interceptor
The 17.2.2 compound-fragment matching splits commands on every unquoted
operator including `|`, so a downstream pipe stage like `grep x` in
`printf 'x\n' | grep x` became a standalone interception candidate and was
routed to the `grep` tool, which searches paths and cannot consume the
previous stage's stdout.

`extractFlatShellCommandSegments` now flags each segment that receives piped
stdin from a single unquoted `|`, and `interceptionCandidates` skips those:
a stdin-consuming stage cannot be replaced by a path-based dedicated tool.
Standalone (`grep pattern path`), first-stage (`grep x file | wc`), and
`&&`/`||`/`;`-sequenced commands still match.

Fixes #7496
2026-08-03 12:36:36 +00:00

149 lines
4.6 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()];
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 };
}