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.
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
* 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 */
|
||||
@@ -32,6 +33,78 @@ function compileRules(rules: BashInterceptorRule[]): Array<{ rule: BashIntercept
|
||||
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.
|
||||
*
|
||||
@@ -43,10 +116,10 @@ export function checkBashInterception(
|
||||
command: string,
|
||||
availableTools: string[],
|
||||
rules: BashInterceptorRule[] = DEFAULT_BASH_INTERCEPTOR_RULES,
|
||||
originalCommand = command,
|
||||
): InterceptionResult {
|
||||
// Normalize command for pattern matching
|
||||
const normalizedCommand = command.trim();
|
||||
const compiled = compileRules(rules);
|
||||
const candidates = interceptionCandidates(command);
|
||||
|
||||
for (const { rule, regex } of compiled) {
|
||||
// Only block if the suggested tool is actually available
|
||||
@@ -54,12 +127,16 @@ export function checkBashInterception(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (regex.test(normalizedCommand)) {
|
||||
return {
|
||||
block: true,
|
||||
message: `Blocked: ${rule.message}\n\nOriginal command: ${command}`,
|
||||
suggestedTool: rule.tool,
|
||||
};
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user