From 0a380c3e85bb4dad1a299dc0240599d2b0903d81 Mon Sep 17 00:00:00 2001 From: Jamkris Date: Thu, 14 May 2026 11:10:40 +0900 Subject: [PATCH] feat(lib): extract shell command-substitution parser to shared lib MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the `extractCommandSubstitutions` function originally introduced in scripts/hooks/gateguard-fact-force.js (PR #1853 round 2) into scripts/lib/shell-substitution.js so other PreToolUse hooks can reuse the same single-quote-aware, double-quote-aware, nested-subshell-aware parser without duplicating it. No behavior change in this commit — the function body is copied verbatim and exposed via `module.exports`. The next commit wires it into scripts/hooks/pre-bash-dev-server-block.js to close that hook's own subshell-bypass holes. gateguard-fact-force.js still defines its own private copy of the function; consolidating both call sites onto this shared lib is a follow-up worth doing once this PR lands, but is intentionally out of scope here to keep the diff focused on the dev-server-block fix. --- scripts/lib/shell-substitution.js | 109 ++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 scripts/lib/shell-substitution.js diff --git a/scripts/lib/shell-substitution.js b/scripts/lib/shell-substitution.js new file mode 100644 index 00000000..aed1beae --- /dev/null +++ b/scripts/lib/shell-substitution.js @@ -0,0 +1,109 @@ +'use strict'; + +/** + * Extract executable command-substitution bodies from a shell line. + * + * Single quotes are literal, so substitutions inside them are ignored; + * double quotes still permit substitutions, so those bodies are scanned + * before quoted text is stripped. Returns each substitution body plus + * any nested substitutions discovered recursively. + * + * Originally introduced in scripts/hooks/gateguard-fact-force.js + * (PR #1853 round 2). Extracted to a shared lib so other PreToolUse + * hooks that need the same "scan inside `$(...)` and backticks" + * behavior can reuse it without duplicating the parser. + * + * @param {string} input + * @returns {string[]} + */ +function extractCommandSubstitutions(input) { + const source = String(input || ''); + const substitutions = []; + let inSingle = false; + let inDouble = false; + + for (let i = 0; i < source.length; i++) { + const ch = source[i]; + const prev = source[i - 1]; + + if (ch === '\\' && !inSingle) { + i += 1; + continue; + } + + if (ch === "'" && !inDouble && prev !== '\\') { + inSingle = !inSingle; + continue; + } + + if (ch === '"' && !inSingle && prev !== '\\') { + inDouble = !inDouble; + continue; + } + + if (inSingle) { + continue; + } + + if (ch === '`') { + let body = ''; + i += 1; + while (i < source.length) { + const inner = source[i]; + if (inner === '\\') { + body += inner; + if (i + 1 < source.length) { + body += source[i + 1]; + i += 2; + continue; + } + } + if (inner === '`') { + break; + } + body += inner; + i += 1; + } + if (body.trim()) { + substitutions.push(body); + substitutions.push(...extractCommandSubstitutions(body)); + } + continue; + } + + if (ch === '$' && source[i + 1] === '(') { + let depth = 1; + let body = ''; + i += 2; + while (i < source.length && depth > 0) { + const inner = source[i]; + if (inner === '\\') { + body += inner; + if (i + 1 < source.length) { + body += source[i + 1]; + i += 2; + continue; + } + } + if (inner === '(') { + depth += 1; + } else if (inner === ')') { + depth -= 1; + if (depth === 0) { + break; + } + } + body += inner; + i += 1; + } + if (body.trim()) { + substitutions.push(body); + substitutions.push(...extractCommandSubstitutions(body)); + } + } + } + + return substitutions; +} + +module.exports = { extractCommandSubstitutions };