Files
oh-my-pi/packages/coding-agent/src/tools/shell-tokenize.ts
T

185 lines
4.7 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;
}
/**
* Returns the original text of flat shell command segments. Unlike
* `tokenizeShellSegments`, this preserves quoting and escaping so the results
* are safe to match against user-configured regular expressions.
*
* The extractor deliberately declines to split syntax whose execution context
* cannot be determined with this small scanner (heredocs, command substitution,
* backticks, grouping, and malformed quoting). Callers must still check the
* complete input in that case.
*/
export function extractFlatShellCommandSegments(command: string): string[] {
const segments: string[] = [];
let segmentStart = 0;
let inSingle = false;
let inDouble = false;
let atWordStart = true;
const pushSegment = (end: number) => {
const segment = command.slice(segmentStart, end).trim();
if (segment.length > 0) segments.push(segment);
};
for (let i = 0; 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 [];
i++;
continue;
}
if (ch === '"') {
inDouble = false;
continue;
}
if (ch === "`" || (ch === "$" && command[i + 1] === "(")) return [];
continue;
}
if (ch === "'") {
inSingle = true;
atWordStart = false;
continue;
}
if (ch === '"') {
inDouble = true;
atWordStart = false;
continue;
}
if (ch === "\\") {
if (i + 1 >= command.length) return [];
i++;
atWordStart = false;
continue;
}
if (
ch === "`" ||
ch === "(" ||
ch === ")" ||
(ch === "$" && command[i + 1] === "(") ||
(ch === "$" && command[i + 1] === "{") ||
(ch === "<" && command[i + 1] === "<") ||
((ch === "{" || ch === "}") &&
atWordStart &&
(command[i + 1] === undefined || /[ \t\n;]/.test(command[i + 1])))
) {
return [];
}
if (ch === "#" && atWordStart) {
pushSegment(i);
const newline = command.indexOf("\n", i + 1);
if (newline === -1) return segments;
i = newline;
segmentStart = newline + 1;
atWordStart = true;
continue;
}
const isRedirectionOperatorCharacter =
ch === "|"
? command[i - 1] === ">"
: ch === "&"
? command[i - 1] === ">" || command[i - 1] === "<" || command[i + 1] === ">"
: false;
if ((ch === "\n" || ch === ";" || ch === "|" || ch === "&") && !isRedirectionOperatorCharacter) {
pushSegment(i);
if ((ch === "|" || ch === "&") && command[i + 1] === ch) i++;
segmentStart = i + 1;
atWordStart = true;
continue;
}
atWordStart = ch === " " || ch === "\t";
}
if (inSingle || inDouble) return [];
pushSegment(command.length);
return segments;
}