'use strict'; /** @returns {{ body: string, endIndex: number }} */ function readBacktickSubstitution(source, startIndex) { let body = ''; let endIndex = startIndex + 1; while (endIndex < source.length) { const inner = source[endIndex]; if (inner === '\\') { const escaped = source[endIndex + 1]; body = escaped === undefined ? `${body}\\` : `${body}\\${escaped}`; endIndex += escaped === undefined ? 1 : 2; continue; } if (inner === '`') break; body = `${body}${inner}`; endIndex += 1; } return { body, endIndex }; } /** @returns {{ body: string, endIndex: number }} */ function readDollarSubstitution(source, startIndex) { let body = ''; let depth = 1; let inSingle = false; let inDouble = false; let endIndex = startIndex + 2; while (endIndex < source.length && depth > 0) { const inner = source[endIndex]; if (inner === '\\' && !inSingle) { const escaped = source[endIndex + 1]; body = escaped === undefined ? `${body}\\` : `${body}\\${escaped}`; endIndex += escaped === undefined ? 1 : 2; continue; } if (inner === "'" && !inDouble) inSingle = !inSingle; else if (inner === '"' && !inSingle) inDouble = !inDouble; else if (!inSingle && !inDouble && inner === '(') depth += 1; else if (!inSingle && !inDouble && inner === ')') depth -= 1; if (depth > 0) body = `${body}${inner}`; endIndex += depth > 0 ? 1 : 0; } return { body, endIndex }; } /** * Iterate over command-substitution bodies, followed by nested bodies. * Quote characters in an unquoted heredoc are literal only at the outer level; * substitutions still use normal shell quote semantics internally. * * @param {string} input * @param {{ literalOuterQuotes?: boolean }} [options] * @returns {Generator} */ function* iterateCommandSubstitutions(input, options = {}) { const source = String(input || ''); const literalOuterQuotes = options.literalOuterQuotes === true; let inSingle = false; let inDouble = false; for (let i = 0; i < source.length; i += 1) { const ch = source[i]; if (ch === '\\' && !inSingle) { i += 1; continue; } if (!literalOuterQuotes && ch === "'" && !inDouble) { inSingle = !inSingle; continue; } if (!literalOuterQuotes && ch === '"' && !inSingle) { inDouble = !inDouble; continue; } if (inSingle) continue; const span = ch === '`' ? readBacktickSubstitution(source, i) : null; const substitution = ch === '$' && source[i + 1] === '(' ? readDollarSubstitution(source, i) : span; if (!substitution) continue; i = substitution.endIndex; if (!substitution.body.trim()) continue; yield substitution.body; yield* iterateCommandSubstitutions(substitution.body); } } /** * Extract executable command-substitution bodies from a shell line. * * @param {string} input * @param {{ literalOuterQuotes?: boolean }} [options] * @returns {string[]} */ function extractCommandSubstitutions(input, options = {}) { return [...iterateCommandSubstitutions(input, options)]; } // --- Shared span readers ------------------------------------------------------ // The group extractors below all need the same quote-aware skipping of // `$(...)`, backtick and `(...)` spans. Each reader takes the index of the // first character inside the span and returns where scanning should resume. function parenDelta(ch) { if (ch === '(') return 1; if (ch === ')') return -1; return 0; } /** * Toggle quote state for a quote character at the current position. * Returns true when `ch` opened or closed a quote. */ function toggleQuote(ch, prev, quotes) { if (ch === "'" && !quotes.inDouble && prev !== '\\') { quotes.inSingle = !quotes.inSingle; return true; } if (ch === '"' && !quotes.inSingle && prev !== '\\') { quotes.inDouble = !quotes.inDouble; return true; } return false; } /** * Index just past the `)` that closes a paren span whose first inner character * is at `start`, honoring quotes and escapes inside the span. */ function findParenSpanEnd(source, start) { const quotes = { inSingle: false, inDouble: false }; let depth = 1; let i = start; while (i < source.length && depth > 0) { const inner = source[i]; if (inner === '\\' && !quotes.inSingle) { i += 2; continue; } if (!toggleQuote(inner, source[i - 1], quotes) && !quotes.inSingle && !quotes.inDouble) { depth += parenDelta(inner); } i += 1; } return i; } /** * Index of the backtick that closes a span whose first inner character is at * `start`, or `source.length` when the span is unterminated. */ function findBacktickEnd(source, start) { let i = start; while (i < source.length && source[i] !== '`') { if (source[i] === '\\' && i + 1 < source.length) { i += 2; continue; } i += 1; } return i; } /** * Top-level scan step shared by the group extractors: escapes, quote state, * `$(...)` and backtick spans. Returns the last index consumed so a `for` * loop's `i++` resumes after it, or -1 when the caller must inspect `source[i]`. */ function skipOuterSpan(source, i, quotes) { const ch = source[i]; if (ch === '\\' && !quotes.inSingle) return i + 1; if (toggleQuote(ch, source[i - 1], quotes)) return i; if (quotes.inSingle || quotes.inDouble) return i; if (ch === '$' && source[i + 1] === '(') return findParenSpanEnd(source, i + 2) - 1; if (ch === '`') return findBacktickEnd(source, i + 1); return -1; } /** * Read a `(...)` subshell body starting just inside the opening paren. Returns * the body text and the index of the closing paren (or where input ran out). */ function readSubshellBody(source, start) { const quotes = { inSingle: false, inDouble: false }; let depth = 1; let body = ''; let i = start; while (i < source.length && depth > 0) { const inner = source[i]; if (inner === '\\' && !quotes.inSingle) { // Keep the escape and its operand together; a trailing backslash at the // end of an unterminated span is appended once and the scan stops. body += source.slice(i, i + 2); i = Math.min(i + 2, source.length); continue; } if (!toggleQuote(inner, source[i - 1], quotes) && !quotes.inSingle && !quotes.inDouble) { depth += parenDelta(inner); if (depth === 0) break; } body += inner; i += 1; } return { body, end: i }; } /** * Extract bodies of plain `(...)` subshell groups. * * Bash treats `(npm run dev)` as a subshell that executes its contents, but * the regex-light segment splitters used by our PreToolUse hooks don't peer * inside those parens. This helper finds top-level `(...)` groups (skipping * `$(...)` command substitutions and backticks, which `extractCommandSubstitutions` * already covers) and returns each body, recursing for nested groups. * * Quote semantics: * - Single quotes are literal: `'( ... )'` is a string, not a subshell. * - Double quotes are literal *for parens*: `"( ... )"` is a string too — * bash only honors `$( )` inside double quotes, not bare `( )`. * * @param {string} input * @returns {string[]} */ function extractSubshellGroups(input) { const source = String(input || ''); const groups = []; const quotes = { inSingle: false, inDouble: false }; for (let i = 0; i < source.length; i++) { const skipped = skipOuterSpan(source, i, quotes); if (skipped !== -1) { i = skipped; continue; } if (source[i] !== '(') continue; const { body, end } = readSubshellBody(source, i + 1); i = end; if (body.trim()) { groups.push(body); groups.push(...extractSubshellGroups(body)); } } return groups; } /** * Inside a brace body, `$(...)`, backtick and `(...)` spans are copied through * verbatim so a `}` inside them cannot close the group. Returns the index just * past the span, or -1 when `source[i]` does not open one. */ function findNestedSpanEnd(source, i) { const ch = source[i]; if (ch === '$' && source[i + 1] === '(') return findParenSpanEnd(source, i + 2); if (ch === '`') return Math.min(findBacktickEnd(source, i + 1) + 1, source.length); if (ch === '(') return findParenSpanEnd(source, i + 1); return -1; } // `{` is a reserved word only when followed by whitespace and preceded by the // line start, whitespace, or a shell operator (`;`, `|`, `&`, `(`). function opensBraceGroup(source, i) { if (source[i] !== '{' || !/\s/.test(source[i + 1] || '')) return false; return i === 0 || /[\s;|&(]/.test(source[i - 1]); } /** * Read a `{ ...; }` brace body starting just after the opening brace. Returns * the body text and the index of the closing brace (or where input ran out). */ function readBraceBody(source, start) { const quotes = { inSingle: false, inDouble: false }; let depth = 1; let body = ''; let i = start; while (i < source.length && depth > 0) { const inner = source[i]; const prev = source[i - 1]; if (inner === '\\' && !quotes.inSingle) { body += source.slice(i, i + 2); i = Math.min(i + 2, source.length); continue; } if (toggleQuote(inner, prev, quotes) || quotes.inSingle || quotes.inDouble) { body += inner; i += 1; continue; } const spanEnd = findNestedSpanEnd(source, i); if (spanEnd !== -1) { body += source.slice(i, spanEnd); i = spanEnd; continue; } if (inner === '{' && /\s/.test(source[i + 1] || '')) { // Match the outer-scan boundary rule for nested `{` so tokens like // `foo{` (no boundary, but followed by space via `foo{ bar`) cannot // bump nested depth. if (/[\s;|&(]/.test(prev)) depth += 1; } else if (inner === '}' && (prev === ';' || /\s/.test(prev))) { depth -= 1; if (depth === 0) break; } body += inner; i += 1; } return { body, end: i }; } /** * Extract bodies of `{ ...; }` brace groups. * * Bash brace groups run their body in the *current* shell (unlike `(...)`, * which forks a subshell). Both forms group multiple commands, so for the * purposes of destructive-bash and dev-server detection they are equivalent: * a `rm -rf` or `npm run dev` inside `{ ...; }` still executes. * * Recognition rules match bash's own reserved-word semantics: * - `{` is a reserved word only when followed by whitespace and preceded by * the line start, whitespace, or a shell operator (`;`, `|`, `&`, `(`). * So `{npm run dev}` is NOT a brace group (single token starting with `{`). * - `}` closes the group only when preceded by `;` or whitespace. * So `foo}` inside the body is not a closing brace. * - Single quotes are literal; double quotes are also literal for `{`/`}`. * - `$(...)`, backticks, and plain `(...)` spans are skipped so we don't * double-extract bodies the sibling extractors already cover. * * @param {string} input * @returns {string[]} */ function extractBraceGroups(input) { const source = String(input || ''); const groups = []; const quotes = { inSingle: false, inDouble: false }; for (let i = 0; i < source.length; i++) { const skipped = skipOuterSpan(source, i, quotes); if (skipped !== -1) { i = skipped; continue; } if (source[i] === '(') { i = findParenSpanEnd(source, i + 1) - 1; continue; } if (!opensBraceGroup(source, i)) continue; const { body, end } = readBraceBody(source, i + 1); i = end; if (body.trim()) { groups.push(body); groups.push(...extractBraceGroups(body)); } } return groups; } module.exports = { extractCommandSubstitutions, extractSubshellGroups, extractBraceGroups };