/** * 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; } /** * A flat shell command segment with the context needed to decide interception. * * @see extractFlatShellCommandSegments */ interface FlatShellCommandSegment { /** Original segment text with quoting and escaping preserved. */ text: string; /** * True when this segment consumes the previous stage's stdout via an * unquoted `|` or `|&`. Blank and comment-only continuation lines preserve * the pending pipe state. Such a stage reads piped stdin, so path-based * dedicated tools (read/grep/glob) cannot replace it. `||`, `;`, `&`, and * `&&` start an independent command and leave this false. */ pipedStdin: boolean; } /** * Returns the flat shell command segments with the original text of each. Unlike * `tokenizeShellSegments`, this preserves quoting and escaping so the results * are safe to match against user-configured regular expressions, and flags * segments that receive piped stdin. * * 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): FlatShellCommandSegment[] { const segments: FlatShellCommandSegment[] = []; let segmentStart = 0; let inSingle = false; let inDouble = false; let atWordStart = true; let currentPiped = false; const pushSegment = (end: number): boolean => { const segment = command.slice(segmentStart, end).trim(); if (segment.length === 0) return false; segments.push({ text: segment, pipedStdin: currentPiped }); return true; }; 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) { const pushed = pushSegment(i); const newline = command.indexOf("\n", i + 1); if (newline === -1) return segments; i = newline; segmentStart = newline + 1; atWordStart = true; // Preserve a pending pipe through a comment-only continuation. if (pushed) currentPiped = false; 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) { const pushed = pushSegment(i); const doubled = (ch === "|" || ch === "&") && command[i + 1] === ch; const pipeStderr = ch === "|" && command[i + 1] === "&"; if (doubled || pipeStderr) i++; // `|` and `|&` pipe into the next segment. Blank continuation // lines preserve that pending state; all other operators reset it. if (pushed || ch !== "\n") currentPiped = ch === "|" && !doubled; segmentStart = i + 1; atWordStart = true; continue; } atWordStart = ch === " " || ch === "\t"; } if (inSingle || inDouble) return []; pushSegment(command.length); return segments; } /** * Shell metacharacters that end an unquoted `cd` target token. A redirect, * extra argument, or any operator in this set means the leading construct is * more than a bare `cd `, so extraction must bail. */ const CD_TARGET_TERMINATORS: Record = { " ": true, "\t": true, "\n": true, "\r": true, "&": true, "|": true, ";": true, "<": true, ">": true, "(": true, ")": true, }; /** * Parses a leading `cd && ...` prefix so the bash tool can route the * target through its structured `cwd` parameter when the model omits it. * * Returns the single path token (quotes and backslash escapes resolved to their * literal value) and the command remainder after the top-level `&&`, or `null` * when the command does not begin with exactly `cd`, one path token, and a * top-level `&&`. The scanner deliberately bails on anything else in the prefix * — redirects (`cd /tmp 2>/dev/null && ...`), extra arguments, or paths needing * shell expansion (`$`, backticks, `(`) — leaving the whole command for the * shell instead of absorbing shell syntax into `cwd`. */ export function extractLeadingCdTarget(command: string): { path: string; rest: string } | null { const prefix = /^cd[ \t]+/.exec(command); if (!prefix) return null; let i = prefix[0].length; let path = ""; let inSingle = false; let inDouble = false; for (; i < command.length; i++) { const ch = command[i]; if (inSingle) { if (ch === "'") { inSingle = false; continue; } path += ch; continue; } if (inDouble) { if (ch === "\\" && i + 1 < command.length) { const next = command[i + 1]; // A line continuation crosses the first physical line. Leave it to // the shell rather than turning the escaped newline into cwd text. if (next === "\n" || next === "\r") return null; if (next === '"' || next === "\\" || next === "$" || next === "`") { path += next; i++; continue; } } if (ch === '"') { inDouble = false; continue; } path += ch; continue; } if (ch === "'") { inSingle = true; continue; } if (ch === '"') { inDouble = true; continue; } if (ch === "\\" && i + 1 < command.length) { // Preserve shell line-continuation semantics by declining extraction. if (command[i + 1] === "\n" || command[i + 1] === "\r") return null; path += command[i + 1]; i++; continue; } if (CD_TARGET_TERMINATORS[ch]) break; path += ch; } // Unterminated quote or empty target: leave the command for the shell. if (inSingle || inDouble || path.length === 0) return null; // A path needing shell expansion can't be resolved literally through cwd. if (/[$`(]/.test(path)) return null; // Skip inter-token whitespace, then require a top-level `&&` (a single `&`, // `||`, `;`, `|`, or a redirect all mean this is not a bare `cd `). while (command[i] === " " || command[i] === "\t") i++; if (command[i] !== "&" || command[i + 1] !== "&") return null; i += 2; while (command[i] === " " || command[i] === "\t") i++; return { path, rest: command.slice(i) }; }