The regex splitter only recognized `&&`, `||`, `;`, `|` and newlines, so a single `&` (background operator) — also a command terminator — slipped a dangerous command past a deny rule (`sleep 1 & rm -rf /tmp/x`), which under approvalMode: yolo executed with no prompt. Extract the shell-aware tokenizer from gh-cache-invalidation into a shared tools/shell-tokenize.ts and reuse it for deny/prompt segmentation. It honors every command boundary (`&&`, `||`, `;`, `|`, single `&`, subshells, newlines) plus quoting and escapes, so both callers share one implementation. Fixes #6695
84 lines
2.0 KiB
TypeScript
84 lines
2.0 KiB
TypeScript
/**
|
|
* Conservative shell command tokenizer shared by the bash approval-pattern
|
|
* matcher and the gh-cache invalidator.
|
|
*
|
|
* Splits a bash command into independent command segments, each a list of word
|
|
* tokens. Handles single/double-quoted strings, backslash escapes, and the
|
|
* standard operators (`;`, `&&`, `||`, `|`, `&`, `(`, `)`, newlines) as segment
|
|
* boundaries so callers treat the pieces as independent command sequences.
|
|
*
|
|
* It is deliberately not a full POSIX parser — heredocs, command substitution,
|
|
* and arithmetic expansion are out of scope; callers fall through when they
|
|
* cannot find the structure they need.
|
|
*/
|
|
export function tokenizeShellSegments(command: string): string[][] {
|
|
const segments: string[][] = [];
|
|
let current: string[] = [];
|
|
let buffer = "";
|
|
let inSingle = false;
|
|
let inDouble = false;
|
|
const pushBuffer = () => {
|
|
if (buffer.length > 0) {
|
|
current.push(buffer);
|
|
buffer = "";
|
|
}
|
|
};
|
|
const pushSegment = () => {
|
|
pushBuffer();
|
|
if (current.length > 0) segments.push(current);
|
|
current = [];
|
|
};
|
|
for (let i = 0; i < command.length; i++) {
|
|
const ch = command[i];
|
|
if (inSingle) {
|
|
if (ch === "'") {
|
|
inSingle = false;
|
|
continue;
|
|
}
|
|
buffer += ch;
|
|
continue;
|
|
}
|
|
if (inDouble) {
|
|
if (ch === "\\" && i + 1 < command.length) {
|
|
const next = command[i + 1];
|
|
if (next === '"' || next === "\\" || next === "$" || next === "`") {
|
|
buffer += next;
|
|
i++;
|
|
continue;
|
|
}
|
|
}
|
|
if (ch === '"') {
|
|
inDouble = false;
|
|
continue;
|
|
}
|
|
buffer += ch;
|
|
continue;
|
|
}
|
|
if (ch === "'") {
|
|
inSingle = true;
|
|
continue;
|
|
}
|
|
if (ch === '"') {
|
|
inDouble = true;
|
|
continue;
|
|
}
|
|
if (ch === "\\" && i + 1 < command.length) {
|
|
buffer += command[i + 1];
|
|
i++;
|
|
continue;
|
|
}
|
|
if (ch === " " || ch === "\t") {
|
|
pushBuffer();
|
|
continue;
|
|
}
|
|
if (ch === "\n" || ch === ";" || ch === "&" || ch === "|" || ch === "(" || ch === ")") {
|
|
pushSegment();
|
|
// `&&`, `||` already collapsed by the segment break above.
|
|
continue;
|
|
}
|
|
buffer += ch;
|
|
}
|
|
pushSegment();
|
|
return segments;
|
|
}
|