/** * Conservative shell command tokenizer shared by the bash approval-pattern * matcher, the gh-cache invalidator, and the session anonymizer. * * 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[] = []; for (const token of lexShellCommand(command)) { if (token.kind === "word") { // Empty quoted words (`''`) carry no argument text. if (token.value.length > 0) current.push(token.value); } else if (/[\n;&|()]/.test(token.raw) && current.length > 0) { segments.push(current); current = []; } } if (current.length > 0) segments.push(current); return segments; } /** One lexical piece of a shell command: a word, or the whitespace/operator run between words. */ export type ShellToken = { kind: "word"; raw: string; value: string } | { kind: "separator"; raw: string }; /** * Lex a command into words and separators with their source text, so callers can rewrite words * and reassemble the command. Word boundaries match {@link tokenizeShellSegments}: `value` is the * quote/escape-removed word, `raw` its verbatim spelling; separators are runs of spaces, tabs, and * the operator characters `\n ; & | ( )`. */ export function lexShellCommand(command: string): ShellToken[] { const tokens: ShellToken[] = []; let value = ""; let wordStart = -1; let separatorStart = -1; let inSingle = false; let inDouble = false; const startWord = (index: number) => { if (wordStart >= 0) return; if (separatorStart >= 0) { tokens.push({ kind: "separator", raw: command.slice(separatorStart, index) }); separatorStart = -1; } wordStart = index; }; const endWord = (index: number) => { if (wordStart < 0) return; tokens.push({ kind: "word", raw: command.slice(wordStart, index), value }); value = ""; wordStart = -1; }; for (let i = 0; i < command.length; i++) { const ch = command[i]; if (inSingle) { if (ch === "'") inSingle = false; else value += ch; continue; } if (inDouble) { if (ch === "\\" && i + 1 < command.length) { const next = command[i + 1]; if (next === '"' || next === "\\" || next === "$" || next === "`") { value += next; i++; continue; } } if (ch === '"') inDouble = false; else value += ch; continue; } if ( ch === " " || ch === "\t" || ch === "\n" || ch === ";" || ch === "&" || ch === "|" || ch === "(" || ch === ")" ) { endWord(i); if (separatorStart < 0) separatorStart = i; continue; } startWord(i); if (ch === "'") { inSingle = true; } else if (ch === '"') { inDouble = true; } else if (ch === "\\" && i + 1 < command.length) { value += command[i + 1]; i++; } else { value += ch; } } endWord(command.length); if (separatorStart >= 0) tokens.push({ kind: "separator", raw: command.slice(separatorStart) }); return tokens; } /** A command in a conservative, flat `&&` chain. */ export interface LiteralShellCommandSegment { /** Original segment text with quoting and escaping preserved. */ text: string; /** Literal argv after shell quote removal. */ argv: string[]; } const SHELL_STATEFUL_COMMANDS = new Set([ "!", ".", // Bash test -v evaluates array subscripts, including command substitutions. "test", "[", "[[", // Zsh also runs behind the Bash tool; its builtins can mutate shell state. "-", "autoload", "bindkey", "bye", "chdir", "disable", "emulate", "functions", "limit", "print", "rehash", "sched", "setopt", "unfunction", "unhash", "unlimit", "unsetopt", "vared", "zle", "zmodload", "zparseopts", "zstyle", "alias", "bg", "break", "builtin", "case", "cd", "command", "coproc", "continue", "declare", "dirs", "disown", "do", "done", "elif", "else", "enable", "esac", "eval", "exec", "exit", "export", "fc", "fg", "fi", "for", "function", "getopts", "hash", "history", "if", "in", "jobs", "let", "logout", "mapfile", "local", "popd", // Bash's printf -v can write BASH_CMDS and retarget a later command. "printf", "pushd", "readonly", "read", "readarray", "return", "select", "set", "shift", "shopt", "source", "suspend", "then", "time", "times", "trap", "typeset", "ulimit", "umask", "unalias", "unset", "until", "wait", "while", ]); const SHELL_INTERPRETER_COMMANDS: Record = { ash: true, bash: true, busybox: true, csh: true, dash: true, env: true, nocorrect: true, noglob: true, fish: true, ksh: true, sh: true, sudo: true, tcsh: true, zsh: true, }; const SHELL_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*\+?=/u; const SHELL_REINTERPRET_OPTION = /^(?:-[^-]*[ce]|--(?:command|eval)(?:=.*)?)$/u; /** * Parses the deliberately small command language eligible for compound-command * approval: two or more literal argv commands joined only by `&&`. * * Anything that needs shell interpretation beyond quote removal is rejected. * In particular, this excludes expansion, globbing, redirection, comments, * assignments, control flow, shell wrappers/interpreters, and commands that * mutate the current shell. Rejection is a normal result: callers must retain * their existing approval behavior. */ export function extractLiteralAndChainSegments(command: string): LiteralShellCommandSegment[] | null { for (let i = 0; i < command.length; i++) { const code = command.charCodeAt(i); if ((code < 0x20 && code !== 0x09) || code === 0x7f) return null; } const segments: LiteralShellCommandSegment[] = []; let argv: string[] = []; let token = ""; let tokenStarted = false; let quote: "'" | '"' | undefined; let segmentStart = 0; const pushToken = () => { if (!tokenStarted) return; argv.push(token); token = ""; tokenStarted = false; }; const pushSegment = (end: number): boolean => { pushToken(); if (argv.length === 0) return false; const executable = argv[0]; if (executable.length === 0) return false; const commandName = executable.slice(Math.max(executable.lastIndexOf("/"), executable.lastIndexOf("\\")) + 1); if ( argv.some(argument => SHELL_ASSIGNMENT.test(argument)) || argv.some((argument, index) => index > 0 && SHELL_REINTERPRET_OPTION.test(argument)) || SHELL_STATEFUL_COMMANDS.has(commandName) || Object.hasOwn(SHELL_INTERPRETER_COMMANDS, commandName) ) { return false; } segments.push({ text: command.slice(segmentStart, end).trim(), argv }); argv = []; return true; }; for (let i = 0; i < command.length; i++) { const ch = command[i]; if (quote === "'") { if (ch === "'") { quote = undefined; } else { token += ch; } continue; } if (quote === '"') { if (ch === '"') { quote = undefined; continue; } if (ch === "$" || ch === "`" || ch === "\n" || ch === "\r") return null; if (ch === "\\") { const next = command[i + 1]; if (next === undefined || next === "\n" || next === "\r") return null; if (next === '"' || next === "\\" || next === "$" || next === "`") { token += next; i++; } else { token += ch; } continue; } token += ch; continue; } if (ch === "'" || ch === '"') { quote = ch; tokenStarted = true; continue; } if (ch === "\\") { const next = command[i + 1]; if (next === undefined || next === "\n" || next === "\r") return null; token += next; tokenStarted = true; i++; continue; } if (ch === " " || ch === "\t") { pushToken(); continue; } if (ch === "&" && command[i + 1] === "&") { if (!pushSegment(i)) return null; i++; segmentStart = i + 1; continue; } if ( ch === "\n" || ch === "\r" || ch === "&" || ch === "|" || ch === ";" || ch === "<" || ch === ">" || ch === "`" || ch === "$" || ch === "(" || ch === ")" || ch === "*" || ch === "?" || ch === "[" || ch === "]" || ch === "{" || ch === "}" || ch === "~" || (ch === "#" && !tokenStarted) ) { return null; } if (ch.charCodeAt(0) < 0x20 || ch.charCodeAt(0) === 0x7f) return null; token += ch; tokenStarted = true; } if (quote || !pushSegment(command.length) || segments.length < 2) return null; 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 && ...` or `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), the command remainder after the separator, and which * separator it was, or `null` when the command does not begin with exactly * `cd`, one path token, and a top-level `&&` or `;`. 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; separator: "&&" | ";" } | 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 `&&` or `;` (a single // `&`, `||`, `|`, a `case` terminator `;;`/`;&`, or a redirect all mean this // is not a bare `cd `). while (command[i] === " " || command[i] === "\t") i++; let separator: "&&" | ";"; if (command[i] === "&" && command[i + 1] === "&") { separator = "&&"; } else if (command[i] === ";" && command[i + 1] !== ";" && command[i + 1] !== "&") { separator = ";"; } else { return null; } i += separator.length; while (command[i] === " " || command[i] === "\t") i++; return { path, rest: command.slice(i), separator }; } /** * Reads one shell-style word from the start of `text`: quoted and unquoted * runs concatenate as a shell would (`a"b c"d` → `ab cd`), quoting/escaping * follows the same rules as {@link tokenizeShellSegments} (no escapes inside * single quotes; `\"`, `\\`, `\$`, `` \` `` inside double quotes; backslash * escapes the next character when unquoted), and the word ends at the first * unquoted whitespace character (space, tab, `\n`, or `\r`) or the end of * the string. * * Shared so callers reading one quoted flag value (`/loop --until "…"`) do not * each maintain their own `indexOf`-based quote scanner, which breaks on an * escaped instance of the closing delimiter. * * Returns `undefined` when `text` is empty or all leading whitespace, and * `"unterminated"` for an unclosed quote. */ export function readShellWord(text: string): { value: string; rest: string } | "unterminated" | undefined { let i = 0; while (i < text.length && /[ \t\n\r]/.test(text[i])) i++; if (i >= text.length) return undefined; let value = ""; let inSingle = false; let inDouble = false; for (; i < text.length; i++) { const ch = text[i]; if (inSingle) { if (ch === "'") { inSingle = false; continue; } value += ch; continue; } if (inDouble) { if (ch === "\\" && i + 1 < text.length) { const next = text[i + 1]; if (next === '"' || next === "\\" || next === "$" || next === "`") { value += next; i++; continue; } } if (ch === '"') { inDouble = false; continue; } value += ch; continue; } if (ch === "'") { inSingle = true; continue; } if (ch === '"') { inDouble = true; continue; } if (ch === "\\" && i + 1 < text.length) { value += text[i + 1]; i++; continue; } if (/[ \t\n\r]/.test(ch)) break; value += ch; } if (inSingle || inDouble) return "unterminated"; return { value, rest: text.slice(i).trim() }; }