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:
Vincent Huang
2026-07-31 19:18:48 +12:00
parent 4df68d6043
commit edb0deebb4
8 changed files with 351 additions and 13 deletions
@@ -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,
};
}
}
}