From 70b86d81c43cb3e1eaf7e278fb8e1bfc73d6c52f Mon Sep 17 00:00:00 2001 From: Jamkris Date: Thu, 14 May 2026 12:22:22 +0900 Subject: [PATCH] fix(lib): track quote state inside command-substitution depth counters Greptile flagged a bypass in PR #1889: `$(echo ")"; (npm run dev))` threaded the depth-counting loops in `extractCommandSubstitutions` and `extractSubshellGroups` to terminate early, because a literal `)` inside double quotes was treated as a real closing paren. The truncated body then ended in a dangling `"` that toggled `inDouble` in the outer scan, masking the subsequent `(npm run dev)` group from extraction. Reproduced (before this commit) by piping the synthetic PreToolUse payload `{"tool_input":{"command":"$(echo \")\"; (npm run dev))"}}` into `scripts/hooks/pre-bash-dev-server-block.js` and observing exit 0 (allow) where the dev pattern is clearly present. Fix: each `$(...)` and `(...)` body loop now tracks its own single/double quote state and only treats `(` / `)` as depth delimiters when outside quotes. The quoted `)` no longer closes the group early, the body now extends to the real closing paren, and the outer scan's quote state remains untouched. After this commit: $ echo '{"tool_input":{"command":"$(echo \")\"; (npm run dev))"}}' \ | node scripts/hooks/pre-bash-dev-server-block.js; echo $? 2 The symmetric form `$(echo "(npm run dev)")` correctly remains allowed (bash does not honor `(...)` inside double quotes). --- scripts/lib/shell-substitution.js | 61 ++++++++++++++++++++++--------- 1 file changed, 44 insertions(+), 17 deletions(-) diff --git a/scripts/lib/shell-substitution.js b/scripts/lib/shell-substitution.js index 0921de1d..dd5d6c74 100644 --- a/scripts/lib/shell-substitution.js +++ b/scripts/lib/shell-substitution.js @@ -74,10 +74,13 @@ function extractCommandSubstitutions(input) { if (ch === '$' && source[i + 1] === '(') { let depth = 1; let body = ''; + let bodyInSingle = false; + let bodyInDouble = false; i += 2; while (i < source.length && depth > 0) { const inner = source[i]; - if (inner === '\\') { + const innerPrev = source[i - 1]; + if (inner === '\\' && !bodyInSingle) { body += inner; if (i + 1 < source.length) { body += source[i + 1]; @@ -85,12 +88,18 @@ function extractCommandSubstitutions(input) { continue; } } - if (inner === '(') { - depth += 1; - } else if (inner === ')') { - depth -= 1; - if (depth === 0) { - break; + if (inner === "'" && !bodyInDouble && innerPrev !== '\\') { + bodyInSingle = !bodyInSingle; + } else if (inner === '"' && !bodyInSingle && innerPrev !== '\\') { + bodyInDouble = !bodyInDouble; + } else if (!bodyInSingle && !bodyInDouble) { + if (inner === '(') { + depth += 1; + } else if (inner === ')') { + depth -= 1; + if (depth === 0) { + break; + } } } body += inner; @@ -154,15 +163,24 @@ function extractSubshellGroups(input) { if (ch === '$' && source[i + 1] === '(') { let depth = 1; + let skipInSingle = false; + let skipInDouble = false; i += 2; while (i < source.length && depth > 0) { const inner = source[i]; - if (inner === '\\') { + const innerPrev = source[i - 1]; + if (inner === '\\' && !skipInSingle) { i += 2; continue; } - if (inner === '(') depth += 1; - else if (inner === ')') depth -= 1; + if (inner === "'" && !skipInDouble && innerPrev !== '\\') { + skipInSingle = !skipInSingle; + } else if (inner === '"' && !skipInSingle && innerPrev !== '\\') { + skipInDouble = !skipInDouble; + } else if (!skipInSingle && !skipInDouble) { + if (inner === '(') depth += 1; + else if (inner === ')') depth -= 1; + } i += 1; } i -= 1; @@ -184,10 +202,13 @@ function extractSubshellGroups(input) { if (ch === '(') { let depth = 1; let body = ''; + let bodyInSingle = false; + let bodyInDouble = false; i += 1; while (i < source.length && depth > 0) { const inner = source[i]; - if (inner === '\\') { + const innerPrev = source[i - 1]; + if (inner === '\\' && !bodyInSingle) { body += inner; if (i + 1 < source.length) { body += source[i + 1]; @@ -195,12 +216,18 @@ function extractSubshellGroups(input) { continue; } } - if (inner === '(') { - depth += 1; - } else if (inner === ')') { - depth -= 1; - if (depth === 0) { - break; + if (inner === "'" && !bodyInDouble && innerPrev !== '\\') { + bodyInSingle = !bodyInSingle; + } else if (inner === '"' && !bodyInSingle && innerPrev !== '\\') { + bodyInDouble = !bodyInDouble; + } else if (!bodyInSingle && !bodyInDouble) { + if (inner === '(') { + depth += 1; + } else if (inner === ')') { + depth -= 1; + if (depth === 0) { + break; + } } } body += inner;