diff --git a/.agents/skills/impeccable/scripts/hook-lib.mjs b/.agents/skills/impeccable/scripts/hook-lib.mjs index b874985a6..9170aa696 100644 --- a/.agents/skills/impeccable/scripts/hook-lib.mjs +++ b/.agents/skills/impeccable/scripts/hook-lib.mjs @@ -1112,7 +1112,19 @@ function formatFindingIgnoreCommand(finding) { function quoteCommandArg(value) { const text = String(value || '').trim(); if (/^[A-Za-z0-9._:-]+$/.test(text)) return text; - return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + // The suggestion is meant to be run on this same machine, so quote for its + // shell. POSIX /bin/sh still expands $(...), backticks, and ${} inside + // double quotes, and these values come from scanned file content (a + // font-family name) or a file path, so untrusted input must be + // single-quoted (issue #476). Windows cmd.exe performs no such command + // substitution, but it treats a single quote as a literal character rather + // than a grouping delimiter, so a value or path containing spaces has to + // stay double-quoted there (Greptile #533). Keep the pre-existing + // double-quote escaping on Windows so that path's behavior is unchanged. + if (process.platform === 'win32') { + return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + } + return `'${text.replace(/'/g, `'\\''`)}'`; } function relativize(filePath, cwd) { diff --git a/.agents/skills/impeccable/scripts/lib/is-generated.mjs b/.agents/skills/impeccable/scripts/lib/is-generated.mjs index 165e1ca80..5e5948ad8 100644 --- a/.agents/skills/impeccable/scripts/lib/is-generated.mjs +++ b/.agents/skills/impeccable/scripts/lib/is-generated.mjs @@ -13,7 +13,7 @@ * within the first ~300 characters — catches non-git projects. */ -import { execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; @@ -41,7 +41,10 @@ export function isGeneratedFile(filePath, options = {}) { function isGitIgnored(absPath, cwd) { try { - execSync(`git check-ignore --quiet ${JSON.stringify(absPath)}`, { + // argv form, never a shell: this runs on every file the live-mode source + // walk reaches, so a hostile filename embedding $(...) or backticks must + // not be interpretable (issue #476). JSON.stringify is not shell quoting. + execFileSync('git', ['check-ignore', '--quiet', absPath], { cwd, stdio: 'ignore', }); diff --git a/.agents/skills/impeccable/scripts/lib/staleness-deep.mjs b/.agents/skills/impeccable/scripts/lib/staleness-deep.mjs index 2c8d6a82f..f3ce76d9f 100644 --- a/.agents/skills/impeccable/scripts/lib/staleness-deep.mjs +++ b/.agents/skills/impeccable/scripts/lib/staleness-deep.mjs @@ -244,7 +244,8 @@ const HOOK_MARKER = /skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs/; // * bundle-relative: node ".agents/.../hook.mjs" // * legacy unquoted: node .claude/.../hook.mjs // * guarded (#399): [ ! -f "PATH" ] || node "PATH" (PATH twice, identical) -// * absolute: node "/Users/.../hook.mjs" (user-level installs) +// * absolute (#476): [ ! -f 'PATH' ] || node 'PATH' (single-quoted since +// the shell-injection fix; older installs double-quote) // * github portable: node "$(git rev-parse --show-toplevel)/.../hook.mjs" // A quoted path wins; the guard's two occurrences are identical, so the first // quoted match is the path. Otherwise fall back to the whitespace/metachar- @@ -255,6 +256,12 @@ function hookScriptTokenFrom(command) { if (!HOOK_MARKER.test(str)) return null; const quoted = str.match(/"([^"]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)"/); if (quoted) return quoted[1]; + // A path containing an apostrophe serializes as '\'' inside single quotes; + // no regex reassembles that, and the bare fallback would misread a fragment + // of it, so return null: the caller never asserts on a path it can't parse. + if (str.includes("'\\''")) return null; + const singleQuoted = str.match(/'([^']*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)'/); + if (singleQuoted) return singleQuoted[1]; const bare = str.match(/([^\s"'|&;()]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)/); return bare ? bare[1] : null; } diff --git a/.agents/skills/impeccable/scripts/live.mjs b/.agents/skills/impeccable/scripts/live.mjs index b04d98f50..7738c3f02 100644 --- a/.agents/skills/impeccable/scripts/live.mjs +++ b/.agents/skills/impeccable/scripts/live.mjs @@ -17,7 +17,7 @@ * node live.mjs --help */ -import { execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -316,11 +316,17 @@ function globToRegex(pattern) { function runScript(name, args, options = {}) { const scriptPath = path.join(__dirname, name); - const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`; try { - return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 }); + // argv form, never a shell: string interpolation into double quotes would + // let a `"` or `$(...)` in any future caller's arg escape into the shell + // (issue #476). + return execFileSync(process.execPath, [scriptPath, ...args], { + encoding: 'utf-8', + cwd: options.cwd || process.cwd(), + timeout: 15_000, + }); } catch (err) { - // execSync throws on non-zero exit; return stdout if any + // execFileSync throws on non-zero exit; return stdout if any return err.stdout || err.message || ''; } } diff --git a/.claude/skills/impeccable/scripts/hook-lib.mjs b/.claude/skills/impeccable/scripts/hook-lib.mjs index b874985a6..9170aa696 100644 --- a/.claude/skills/impeccable/scripts/hook-lib.mjs +++ b/.claude/skills/impeccable/scripts/hook-lib.mjs @@ -1112,7 +1112,19 @@ function formatFindingIgnoreCommand(finding) { function quoteCommandArg(value) { const text = String(value || '').trim(); if (/^[A-Za-z0-9._:-]+$/.test(text)) return text; - return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + // The suggestion is meant to be run on this same machine, so quote for its + // shell. POSIX /bin/sh still expands $(...), backticks, and ${} inside + // double quotes, and these values come from scanned file content (a + // font-family name) or a file path, so untrusted input must be + // single-quoted (issue #476). Windows cmd.exe performs no such command + // substitution, but it treats a single quote as a literal character rather + // than a grouping delimiter, so a value or path containing spaces has to + // stay double-quoted there (Greptile #533). Keep the pre-existing + // double-quote escaping on Windows so that path's behavior is unchanged. + if (process.platform === 'win32') { + return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + } + return `'${text.replace(/'/g, `'\\''`)}'`; } function relativize(filePath, cwd) { diff --git a/.claude/skills/impeccable/scripts/lib/is-generated.mjs b/.claude/skills/impeccable/scripts/lib/is-generated.mjs index 165e1ca80..5e5948ad8 100644 --- a/.claude/skills/impeccable/scripts/lib/is-generated.mjs +++ b/.claude/skills/impeccable/scripts/lib/is-generated.mjs @@ -13,7 +13,7 @@ * within the first ~300 characters — catches non-git projects. */ -import { execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; @@ -41,7 +41,10 @@ export function isGeneratedFile(filePath, options = {}) { function isGitIgnored(absPath, cwd) { try { - execSync(`git check-ignore --quiet ${JSON.stringify(absPath)}`, { + // argv form, never a shell: this runs on every file the live-mode source + // walk reaches, so a hostile filename embedding $(...) or backticks must + // not be interpretable (issue #476). JSON.stringify is not shell quoting. + execFileSync('git', ['check-ignore', '--quiet', absPath], { cwd, stdio: 'ignore', }); diff --git a/.claude/skills/impeccable/scripts/lib/staleness-deep.mjs b/.claude/skills/impeccable/scripts/lib/staleness-deep.mjs index 2c8d6a82f..f3ce76d9f 100644 --- a/.claude/skills/impeccable/scripts/lib/staleness-deep.mjs +++ b/.claude/skills/impeccable/scripts/lib/staleness-deep.mjs @@ -244,7 +244,8 @@ const HOOK_MARKER = /skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs/; // * bundle-relative: node ".agents/.../hook.mjs" // * legacy unquoted: node .claude/.../hook.mjs // * guarded (#399): [ ! -f "PATH" ] || node "PATH" (PATH twice, identical) -// * absolute: node "/Users/.../hook.mjs" (user-level installs) +// * absolute (#476): [ ! -f 'PATH' ] || node 'PATH' (single-quoted since +// the shell-injection fix; older installs double-quote) // * github portable: node "$(git rev-parse --show-toplevel)/.../hook.mjs" // A quoted path wins; the guard's two occurrences are identical, so the first // quoted match is the path. Otherwise fall back to the whitespace/metachar- @@ -255,6 +256,12 @@ function hookScriptTokenFrom(command) { if (!HOOK_MARKER.test(str)) return null; const quoted = str.match(/"([^"]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)"/); if (quoted) return quoted[1]; + // A path containing an apostrophe serializes as '\'' inside single quotes; + // no regex reassembles that, and the bare fallback would misread a fragment + // of it, so return null: the caller never asserts on a path it can't parse. + if (str.includes("'\\''")) return null; + const singleQuoted = str.match(/'([^']*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)'/); + if (singleQuoted) return singleQuoted[1]; const bare = str.match(/([^\s"'|&;()]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)/); return bare ? bare[1] : null; } diff --git a/.claude/skills/impeccable/scripts/live.mjs b/.claude/skills/impeccable/scripts/live.mjs index b04d98f50..7738c3f02 100644 --- a/.claude/skills/impeccable/scripts/live.mjs +++ b/.claude/skills/impeccable/scripts/live.mjs @@ -17,7 +17,7 @@ * node live.mjs --help */ -import { execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -316,11 +316,17 @@ function globToRegex(pattern) { function runScript(name, args, options = {}) { const scriptPath = path.join(__dirname, name); - const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`; try { - return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 }); + // argv form, never a shell: string interpolation into double quotes would + // let a `"` or `$(...)` in any future caller's arg escape into the shell + // (issue #476). + return execFileSync(process.execPath, [scriptPath, ...args], { + encoding: 'utf-8', + cwd: options.cwd || process.cwd(), + timeout: 15_000, + }); } catch (err) { - // execSync throws on non-zero exit; return stdout if any + // execFileSync throws on non-zero exit; return stdout if any return err.stdout || err.message || ''; } } diff --git a/.cursor/skills/impeccable/scripts/hook-lib.mjs b/.cursor/skills/impeccable/scripts/hook-lib.mjs index b874985a6..9170aa696 100644 --- a/.cursor/skills/impeccable/scripts/hook-lib.mjs +++ b/.cursor/skills/impeccable/scripts/hook-lib.mjs @@ -1112,7 +1112,19 @@ function formatFindingIgnoreCommand(finding) { function quoteCommandArg(value) { const text = String(value || '').trim(); if (/^[A-Za-z0-9._:-]+$/.test(text)) return text; - return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + // The suggestion is meant to be run on this same machine, so quote for its + // shell. POSIX /bin/sh still expands $(...), backticks, and ${} inside + // double quotes, and these values come from scanned file content (a + // font-family name) or a file path, so untrusted input must be + // single-quoted (issue #476). Windows cmd.exe performs no such command + // substitution, but it treats a single quote as a literal character rather + // than a grouping delimiter, so a value or path containing spaces has to + // stay double-quoted there (Greptile #533). Keep the pre-existing + // double-quote escaping on Windows so that path's behavior is unchanged. + if (process.platform === 'win32') { + return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + } + return `'${text.replace(/'/g, `'\\''`)}'`; } function relativize(filePath, cwd) { diff --git a/.cursor/skills/impeccable/scripts/lib/is-generated.mjs b/.cursor/skills/impeccable/scripts/lib/is-generated.mjs index 165e1ca80..5e5948ad8 100644 --- a/.cursor/skills/impeccable/scripts/lib/is-generated.mjs +++ b/.cursor/skills/impeccable/scripts/lib/is-generated.mjs @@ -13,7 +13,7 @@ * within the first ~300 characters — catches non-git projects. */ -import { execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; @@ -41,7 +41,10 @@ export function isGeneratedFile(filePath, options = {}) { function isGitIgnored(absPath, cwd) { try { - execSync(`git check-ignore --quiet ${JSON.stringify(absPath)}`, { + // argv form, never a shell: this runs on every file the live-mode source + // walk reaches, so a hostile filename embedding $(...) or backticks must + // not be interpretable (issue #476). JSON.stringify is not shell quoting. + execFileSync('git', ['check-ignore', '--quiet', absPath], { cwd, stdio: 'ignore', }); diff --git a/.cursor/skills/impeccable/scripts/lib/staleness-deep.mjs b/.cursor/skills/impeccable/scripts/lib/staleness-deep.mjs index 2c8d6a82f..f3ce76d9f 100644 --- a/.cursor/skills/impeccable/scripts/lib/staleness-deep.mjs +++ b/.cursor/skills/impeccable/scripts/lib/staleness-deep.mjs @@ -244,7 +244,8 @@ const HOOK_MARKER = /skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs/; // * bundle-relative: node ".agents/.../hook.mjs" // * legacy unquoted: node .claude/.../hook.mjs // * guarded (#399): [ ! -f "PATH" ] || node "PATH" (PATH twice, identical) -// * absolute: node "/Users/.../hook.mjs" (user-level installs) +// * absolute (#476): [ ! -f 'PATH' ] || node 'PATH' (single-quoted since +// the shell-injection fix; older installs double-quote) // * github portable: node "$(git rev-parse --show-toplevel)/.../hook.mjs" // A quoted path wins; the guard's two occurrences are identical, so the first // quoted match is the path. Otherwise fall back to the whitespace/metachar- @@ -255,6 +256,12 @@ function hookScriptTokenFrom(command) { if (!HOOK_MARKER.test(str)) return null; const quoted = str.match(/"([^"]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)"/); if (quoted) return quoted[1]; + // A path containing an apostrophe serializes as '\'' inside single quotes; + // no regex reassembles that, and the bare fallback would misread a fragment + // of it, so return null: the caller never asserts on a path it can't parse. + if (str.includes("'\\''")) return null; + const singleQuoted = str.match(/'([^']*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)'/); + if (singleQuoted) return singleQuoted[1]; const bare = str.match(/([^\s"'|&;()]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)/); return bare ? bare[1] : null; } diff --git a/.cursor/skills/impeccable/scripts/live.mjs b/.cursor/skills/impeccable/scripts/live.mjs index b04d98f50..7738c3f02 100644 --- a/.cursor/skills/impeccable/scripts/live.mjs +++ b/.cursor/skills/impeccable/scripts/live.mjs @@ -17,7 +17,7 @@ * node live.mjs --help */ -import { execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -316,11 +316,17 @@ function globToRegex(pattern) { function runScript(name, args, options = {}) { const scriptPath = path.join(__dirname, name); - const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`; try { - return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 }); + // argv form, never a shell: string interpolation into double quotes would + // let a `"` or `$(...)` in any future caller's arg escape into the shell + // (issue #476). + return execFileSync(process.execPath, [scriptPath, ...args], { + encoding: 'utf-8', + cwd: options.cwd || process.cwd(), + timeout: 15_000, + }); } catch (err) { - // execSync throws on non-zero exit; return stdout if any + // execFileSync throws on non-zero exit; return stdout if any return err.stdout || err.message || ''; } } diff --git a/.gemini/skills/impeccable/scripts/hook-lib.mjs b/.gemini/skills/impeccable/scripts/hook-lib.mjs index b874985a6..9170aa696 100644 --- a/.gemini/skills/impeccable/scripts/hook-lib.mjs +++ b/.gemini/skills/impeccable/scripts/hook-lib.mjs @@ -1112,7 +1112,19 @@ function formatFindingIgnoreCommand(finding) { function quoteCommandArg(value) { const text = String(value || '').trim(); if (/^[A-Za-z0-9._:-]+$/.test(text)) return text; - return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + // The suggestion is meant to be run on this same machine, so quote for its + // shell. POSIX /bin/sh still expands $(...), backticks, and ${} inside + // double quotes, and these values come from scanned file content (a + // font-family name) or a file path, so untrusted input must be + // single-quoted (issue #476). Windows cmd.exe performs no such command + // substitution, but it treats a single quote as a literal character rather + // than a grouping delimiter, so a value or path containing spaces has to + // stay double-quoted there (Greptile #533). Keep the pre-existing + // double-quote escaping on Windows so that path's behavior is unchanged. + if (process.platform === 'win32') { + return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + } + return `'${text.replace(/'/g, `'\\''`)}'`; } function relativize(filePath, cwd) { diff --git a/.gemini/skills/impeccable/scripts/lib/is-generated.mjs b/.gemini/skills/impeccable/scripts/lib/is-generated.mjs index 165e1ca80..5e5948ad8 100644 --- a/.gemini/skills/impeccable/scripts/lib/is-generated.mjs +++ b/.gemini/skills/impeccable/scripts/lib/is-generated.mjs @@ -13,7 +13,7 @@ * within the first ~300 characters — catches non-git projects. */ -import { execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; @@ -41,7 +41,10 @@ export function isGeneratedFile(filePath, options = {}) { function isGitIgnored(absPath, cwd) { try { - execSync(`git check-ignore --quiet ${JSON.stringify(absPath)}`, { + // argv form, never a shell: this runs on every file the live-mode source + // walk reaches, so a hostile filename embedding $(...) or backticks must + // not be interpretable (issue #476). JSON.stringify is not shell quoting. + execFileSync('git', ['check-ignore', '--quiet', absPath], { cwd, stdio: 'ignore', }); diff --git a/.gemini/skills/impeccable/scripts/lib/staleness-deep.mjs b/.gemini/skills/impeccable/scripts/lib/staleness-deep.mjs index 2c8d6a82f..f3ce76d9f 100644 --- a/.gemini/skills/impeccable/scripts/lib/staleness-deep.mjs +++ b/.gemini/skills/impeccable/scripts/lib/staleness-deep.mjs @@ -244,7 +244,8 @@ const HOOK_MARKER = /skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs/; // * bundle-relative: node ".agents/.../hook.mjs" // * legacy unquoted: node .claude/.../hook.mjs // * guarded (#399): [ ! -f "PATH" ] || node "PATH" (PATH twice, identical) -// * absolute: node "/Users/.../hook.mjs" (user-level installs) +// * absolute (#476): [ ! -f 'PATH' ] || node 'PATH' (single-quoted since +// the shell-injection fix; older installs double-quote) // * github portable: node "$(git rev-parse --show-toplevel)/.../hook.mjs" // A quoted path wins; the guard's two occurrences are identical, so the first // quoted match is the path. Otherwise fall back to the whitespace/metachar- @@ -255,6 +256,12 @@ function hookScriptTokenFrom(command) { if (!HOOK_MARKER.test(str)) return null; const quoted = str.match(/"([^"]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)"/); if (quoted) return quoted[1]; + // A path containing an apostrophe serializes as '\'' inside single quotes; + // no regex reassembles that, and the bare fallback would misread a fragment + // of it, so return null: the caller never asserts on a path it can't parse. + if (str.includes("'\\''")) return null; + const singleQuoted = str.match(/'([^']*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)'/); + if (singleQuoted) return singleQuoted[1]; const bare = str.match(/([^\s"'|&;()]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)/); return bare ? bare[1] : null; } diff --git a/.gemini/skills/impeccable/scripts/live.mjs b/.gemini/skills/impeccable/scripts/live.mjs index b04d98f50..7738c3f02 100644 --- a/.gemini/skills/impeccable/scripts/live.mjs +++ b/.gemini/skills/impeccable/scripts/live.mjs @@ -17,7 +17,7 @@ * node live.mjs --help */ -import { execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -316,11 +316,17 @@ function globToRegex(pattern) { function runScript(name, args, options = {}) { const scriptPath = path.join(__dirname, name); - const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`; try { - return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 }); + // argv form, never a shell: string interpolation into double quotes would + // let a `"` or `$(...)` in any future caller's arg escape into the shell + // (issue #476). + return execFileSync(process.execPath, [scriptPath, ...args], { + encoding: 'utf-8', + cwd: options.cwd || process.cwd(), + timeout: 15_000, + }); } catch (err) { - // execSync throws on non-zero exit; return stdout if any + // execFileSync throws on non-zero exit; return stdout if any return err.stdout || err.message || ''; } } diff --git a/.github/skills/impeccable/scripts/hook-lib.mjs b/.github/skills/impeccable/scripts/hook-lib.mjs index b874985a6..9170aa696 100644 --- a/.github/skills/impeccable/scripts/hook-lib.mjs +++ b/.github/skills/impeccable/scripts/hook-lib.mjs @@ -1112,7 +1112,19 @@ function formatFindingIgnoreCommand(finding) { function quoteCommandArg(value) { const text = String(value || '').trim(); if (/^[A-Za-z0-9._:-]+$/.test(text)) return text; - return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + // The suggestion is meant to be run on this same machine, so quote for its + // shell. POSIX /bin/sh still expands $(...), backticks, and ${} inside + // double quotes, and these values come from scanned file content (a + // font-family name) or a file path, so untrusted input must be + // single-quoted (issue #476). Windows cmd.exe performs no such command + // substitution, but it treats a single quote as a literal character rather + // than a grouping delimiter, so a value or path containing spaces has to + // stay double-quoted there (Greptile #533). Keep the pre-existing + // double-quote escaping on Windows so that path's behavior is unchanged. + if (process.platform === 'win32') { + return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + } + return `'${text.replace(/'/g, `'\\''`)}'`; } function relativize(filePath, cwd) { diff --git a/.github/skills/impeccable/scripts/lib/is-generated.mjs b/.github/skills/impeccable/scripts/lib/is-generated.mjs index 165e1ca80..5e5948ad8 100644 --- a/.github/skills/impeccable/scripts/lib/is-generated.mjs +++ b/.github/skills/impeccable/scripts/lib/is-generated.mjs @@ -13,7 +13,7 @@ * within the first ~300 characters — catches non-git projects. */ -import { execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; @@ -41,7 +41,10 @@ export function isGeneratedFile(filePath, options = {}) { function isGitIgnored(absPath, cwd) { try { - execSync(`git check-ignore --quiet ${JSON.stringify(absPath)}`, { + // argv form, never a shell: this runs on every file the live-mode source + // walk reaches, so a hostile filename embedding $(...) or backticks must + // not be interpretable (issue #476). JSON.stringify is not shell quoting. + execFileSync('git', ['check-ignore', '--quiet', absPath], { cwd, stdio: 'ignore', }); diff --git a/.github/skills/impeccable/scripts/lib/staleness-deep.mjs b/.github/skills/impeccable/scripts/lib/staleness-deep.mjs index 2c8d6a82f..f3ce76d9f 100644 --- a/.github/skills/impeccable/scripts/lib/staleness-deep.mjs +++ b/.github/skills/impeccable/scripts/lib/staleness-deep.mjs @@ -244,7 +244,8 @@ const HOOK_MARKER = /skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs/; // * bundle-relative: node ".agents/.../hook.mjs" // * legacy unquoted: node .claude/.../hook.mjs // * guarded (#399): [ ! -f "PATH" ] || node "PATH" (PATH twice, identical) -// * absolute: node "/Users/.../hook.mjs" (user-level installs) +// * absolute (#476): [ ! -f 'PATH' ] || node 'PATH' (single-quoted since +// the shell-injection fix; older installs double-quote) // * github portable: node "$(git rev-parse --show-toplevel)/.../hook.mjs" // A quoted path wins; the guard's two occurrences are identical, so the first // quoted match is the path. Otherwise fall back to the whitespace/metachar- @@ -255,6 +256,12 @@ function hookScriptTokenFrom(command) { if (!HOOK_MARKER.test(str)) return null; const quoted = str.match(/"([^"]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)"/); if (quoted) return quoted[1]; + // A path containing an apostrophe serializes as '\'' inside single quotes; + // no regex reassembles that, and the bare fallback would misread a fragment + // of it, so return null: the caller never asserts on a path it can't parse. + if (str.includes("'\\''")) return null; + const singleQuoted = str.match(/'([^']*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)'/); + if (singleQuoted) return singleQuoted[1]; const bare = str.match(/([^\s"'|&;()]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)/); return bare ? bare[1] : null; } diff --git a/.github/skills/impeccable/scripts/live.mjs b/.github/skills/impeccable/scripts/live.mjs index b04d98f50..7738c3f02 100644 --- a/.github/skills/impeccable/scripts/live.mjs +++ b/.github/skills/impeccable/scripts/live.mjs @@ -17,7 +17,7 @@ * node live.mjs --help */ -import { execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -316,11 +316,17 @@ function globToRegex(pattern) { function runScript(name, args, options = {}) { const scriptPath = path.join(__dirname, name); - const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`; try { - return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 }); + // argv form, never a shell: string interpolation into double quotes would + // let a `"` or `$(...)` in any future caller's arg escape into the shell + // (issue #476). + return execFileSync(process.execPath, [scriptPath, ...args], { + encoding: 'utf-8', + cwd: options.cwd || process.cwd(), + timeout: 15_000, + }); } catch (err) { - // execSync throws on non-zero exit; return stdout if any + // execFileSync throws on non-zero exit; return stdout if any return err.stdout || err.message || ''; } } diff --git a/.grok/skills/impeccable/scripts/hook-lib.mjs b/.grok/skills/impeccable/scripts/hook-lib.mjs index b874985a6..9170aa696 100644 --- a/.grok/skills/impeccable/scripts/hook-lib.mjs +++ b/.grok/skills/impeccable/scripts/hook-lib.mjs @@ -1112,7 +1112,19 @@ function formatFindingIgnoreCommand(finding) { function quoteCommandArg(value) { const text = String(value || '').trim(); if (/^[A-Za-z0-9._:-]+$/.test(text)) return text; - return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + // The suggestion is meant to be run on this same machine, so quote for its + // shell. POSIX /bin/sh still expands $(...), backticks, and ${} inside + // double quotes, and these values come from scanned file content (a + // font-family name) or a file path, so untrusted input must be + // single-quoted (issue #476). Windows cmd.exe performs no such command + // substitution, but it treats a single quote as a literal character rather + // than a grouping delimiter, so a value or path containing spaces has to + // stay double-quoted there (Greptile #533). Keep the pre-existing + // double-quote escaping on Windows so that path's behavior is unchanged. + if (process.platform === 'win32') { + return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + } + return `'${text.replace(/'/g, `'\\''`)}'`; } function relativize(filePath, cwd) { diff --git a/.grok/skills/impeccable/scripts/lib/is-generated.mjs b/.grok/skills/impeccable/scripts/lib/is-generated.mjs index 165e1ca80..5e5948ad8 100644 --- a/.grok/skills/impeccable/scripts/lib/is-generated.mjs +++ b/.grok/skills/impeccable/scripts/lib/is-generated.mjs @@ -13,7 +13,7 @@ * within the first ~300 characters — catches non-git projects. */ -import { execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; @@ -41,7 +41,10 @@ export function isGeneratedFile(filePath, options = {}) { function isGitIgnored(absPath, cwd) { try { - execSync(`git check-ignore --quiet ${JSON.stringify(absPath)}`, { + // argv form, never a shell: this runs on every file the live-mode source + // walk reaches, so a hostile filename embedding $(...) or backticks must + // not be interpretable (issue #476). JSON.stringify is not shell quoting. + execFileSync('git', ['check-ignore', '--quiet', absPath], { cwd, stdio: 'ignore', }); diff --git a/.grok/skills/impeccable/scripts/lib/staleness-deep.mjs b/.grok/skills/impeccable/scripts/lib/staleness-deep.mjs index 2c8d6a82f..f3ce76d9f 100644 --- a/.grok/skills/impeccable/scripts/lib/staleness-deep.mjs +++ b/.grok/skills/impeccable/scripts/lib/staleness-deep.mjs @@ -244,7 +244,8 @@ const HOOK_MARKER = /skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs/; // * bundle-relative: node ".agents/.../hook.mjs" // * legacy unquoted: node .claude/.../hook.mjs // * guarded (#399): [ ! -f "PATH" ] || node "PATH" (PATH twice, identical) -// * absolute: node "/Users/.../hook.mjs" (user-level installs) +// * absolute (#476): [ ! -f 'PATH' ] || node 'PATH' (single-quoted since +// the shell-injection fix; older installs double-quote) // * github portable: node "$(git rev-parse --show-toplevel)/.../hook.mjs" // A quoted path wins; the guard's two occurrences are identical, so the first // quoted match is the path. Otherwise fall back to the whitespace/metachar- @@ -255,6 +256,12 @@ function hookScriptTokenFrom(command) { if (!HOOK_MARKER.test(str)) return null; const quoted = str.match(/"([^"]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)"/); if (quoted) return quoted[1]; + // A path containing an apostrophe serializes as '\'' inside single quotes; + // no regex reassembles that, and the bare fallback would misread a fragment + // of it, so return null: the caller never asserts on a path it can't parse. + if (str.includes("'\\''")) return null; + const singleQuoted = str.match(/'([^']*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)'/); + if (singleQuoted) return singleQuoted[1]; const bare = str.match(/([^\s"'|&;()]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)/); return bare ? bare[1] : null; } diff --git a/.grok/skills/impeccable/scripts/live.mjs b/.grok/skills/impeccable/scripts/live.mjs index b04d98f50..7738c3f02 100644 --- a/.grok/skills/impeccable/scripts/live.mjs +++ b/.grok/skills/impeccable/scripts/live.mjs @@ -17,7 +17,7 @@ * node live.mjs --help */ -import { execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -316,11 +316,17 @@ function globToRegex(pattern) { function runScript(name, args, options = {}) { const scriptPath = path.join(__dirname, name); - const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`; try { - return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 }); + // argv form, never a shell: string interpolation into double quotes would + // let a `"` or `$(...)` in any future caller's arg escape into the shell + // (issue #476). + return execFileSync(process.execPath, [scriptPath, ...args], { + encoding: 'utf-8', + cwd: options.cwd || process.cwd(), + timeout: 15_000, + }); } catch (err) { - // execSync throws on non-zero exit; return stdout if any + // execFileSync throws on non-zero exit; return stdout if any return err.stdout || err.message || ''; } } diff --git a/.kiro/skills/impeccable/scripts/hook-lib.mjs b/.kiro/skills/impeccable/scripts/hook-lib.mjs index b874985a6..9170aa696 100644 --- a/.kiro/skills/impeccable/scripts/hook-lib.mjs +++ b/.kiro/skills/impeccable/scripts/hook-lib.mjs @@ -1112,7 +1112,19 @@ function formatFindingIgnoreCommand(finding) { function quoteCommandArg(value) { const text = String(value || '').trim(); if (/^[A-Za-z0-9._:-]+$/.test(text)) return text; - return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + // The suggestion is meant to be run on this same machine, so quote for its + // shell. POSIX /bin/sh still expands $(...), backticks, and ${} inside + // double quotes, and these values come from scanned file content (a + // font-family name) or a file path, so untrusted input must be + // single-quoted (issue #476). Windows cmd.exe performs no such command + // substitution, but it treats a single quote as a literal character rather + // than a grouping delimiter, so a value or path containing spaces has to + // stay double-quoted there (Greptile #533). Keep the pre-existing + // double-quote escaping on Windows so that path's behavior is unchanged. + if (process.platform === 'win32') { + return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + } + return `'${text.replace(/'/g, `'\\''`)}'`; } function relativize(filePath, cwd) { diff --git a/.kiro/skills/impeccable/scripts/lib/is-generated.mjs b/.kiro/skills/impeccable/scripts/lib/is-generated.mjs index 165e1ca80..5e5948ad8 100644 --- a/.kiro/skills/impeccable/scripts/lib/is-generated.mjs +++ b/.kiro/skills/impeccable/scripts/lib/is-generated.mjs @@ -13,7 +13,7 @@ * within the first ~300 characters — catches non-git projects. */ -import { execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; @@ -41,7 +41,10 @@ export function isGeneratedFile(filePath, options = {}) { function isGitIgnored(absPath, cwd) { try { - execSync(`git check-ignore --quiet ${JSON.stringify(absPath)}`, { + // argv form, never a shell: this runs on every file the live-mode source + // walk reaches, so a hostile filename embedding $(...) or backticks must + // not be interpretable (issue #476). JSON.stringify is not shell quoting. + execFileSync('git', ['check-ignore', '--quiet', absPath], { cwd, stdio: 'ignore', }); diff --git a/.kiro/skills/impeccable/scripts/lib/staleness-deep.mjs b/.kiro/skills/impeccable/scripts/lib/staleness-deep.mjs index 2c8d6a82f..f3ce76d9f 100644 --- a/.kiro/skills/impeccable/scripts/lib/staleness-deep.mjs +++ b/.kiro/skills/impeccable/scripts/lib/staleness-deep.mjs @@ -244,7 +244,8 @@ const HOOK_MARKER = /skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs/; // * bundle-relative: node ".agents/.../hook.mjs" // * legacy unquoted: node .claude/.../hook.mjs // * guarded (#399): [ ! -f "PATH" ] || node "PATH" (PATH twice, identical) -// * absolute: node "/Users/.../hook.mjs" (user-level installs) +// * absolute (#476): [ ! -f 'PATH' ] || node 'PATH' (single-quoted since +// the shell-injection fix; older installs double-quote) // * github portable: node "$(git rev-parse --show-toplevel)/.../hook.mjs" // A quoted path wins; the guard's two occurrences are identical, so the first // quoted match is the path. Otherwise fall back to the whitespace/metachar- @@ -255,6 +256,12 @@ function hookScriptTokenFrom(command) { if (!HOOK_MARKER.test(str)) return null; const quoted = str.match(/"([^"]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)"/); if (quoted) return quoted[1]; + // A path containing an apostrophe serializes as '\'' inside single quotes; + // no regex reassembles that, and the bare fallback would misread a fragment + // of it, so return null: the caller never asserts on a path it can't parse. + if (str.includes("'\\''")) return null; + const singleQuoted = str.match(/'([^']*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)'/); + if (singleQuoted) return singleQuoted[1]; const bare = str.match(/([^\s"'|&;()]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)/); return bare ? bare[1] : null; } diff --git a/.kiro/skills/impeccable/scripts/live.mjs b/.kiro/skills/impeccable/scripts/live.mjs index b04d98f50..7738c3f02 100644 --- a/.kiro/skills/impeccable/scripts/live.mjs +++ b/.kiro/skills/impeccable/scripts/live.mjs @@ -17,7 +17,7 @@ * node live.mjs --help */ -import { execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -316,11 +316,17 @@ function globToRegex(pattern) { function runScript(name, args, options = {}) { const scriptPath = path.join(__dirname, name); - const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`; try { - return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 }); + // argv form, never a shell: string interpolation into double quotes would + // let a `"` or `$(...)` in any future caller's arg escape into the shell + // (issue #476). + return execFileSync(process.execPath, [scriptPath, ...args], { + encoding: 'utf-8', + cwd: options.cwd || process.cwd(), + timeout: 15_000, + }); } catch (err) { - // execSync throws on non-zero exit; return stdout if any + // execFileSync throws on non-zero exit; return stdout if any return err.stdout || err.message || ''; } } diff --git a/.opencode/skills/impeccable/scripts/hook-lib.mjs b/.opencode/skills/impeccable/scripts/hook-lib.mjs index b874985a6..9170aa696 100644 --- a/.opencode/skills/impeccable/scripts/hook-lib.mjs +++ b/.opencode/skills/impeccable/scripts/hook-lib.mjs @@ -1112,7 +1112,19 @@ function formatFindingIgnoreCommand(finding) { function quoteCommandArg(value) { const text = String(value || '').trim(); if (/^[A-Za-z0-9._:-]+$/.test(text)) return text; - return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + // The suggestion is meant to be run on this same machine, so quote for its + // shell. POSIX /bin/sh still expands $(...), backticks, and ${} inside + // double quotes, and these values come from scanned file content (a + // font-family name) or a file path, so untrusted input must be + // single-quoted (issue #476). Windows cmd.exe performs no such command + // substitution, but it treats a single quote as a literal character rather + // than a grouping delimiter, so a value or path containing spaces has to + // stay double-quoted there (Greptile #533). Keep the pre-existing + // double-quote escaping on Windows so that path's behavior is unchanged. + if (process.platform === 'win32') { + return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + } + return `'${text.replace(/'/g, `'\\''`)}'`; } function relativize(filePath, cwd) { diff --git a/.opencode/skills/impeccable/scripts/lib/is-generated.mjs b/.opencode/skills/impeccable/scripts/lib/is-generated.mjs index 165e1ca80..5e5948ad8 100644 --- a/.opencode/skills/impeccable/scripts/lib/is-generated.mjs +++ b/.opencode/skills/impeccable/scripts/lib/is-generated.mjs @@ -13,7 +13,7 @@ * within the first ~300 characters — catches non-git projects. */ -import { execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; @@ -41,7 +41,10 @@ export function isGeneratedFile(filePath, options = {}) { function isGitIgnored(absPath, cwd) { try { - execSync(`git check-ignore --quiet ${JSON.stringify(absPath)}`, { + // argv form, never a shell: this runs on every file the live-mode source + // walk reaches, so a hostile filename embedding $(...) or backticks must + // not be interpretable (issue #476). JSON.stringify is not shell quoting. + execFileSync('git', ['check-ignore', '--quiet', absPath], { cwd, stdio: 'ignore', }); diff --git a/.opencode/skills/impeccable/scripts/lib/staleness-deep.mjs b/.opencode/skills/impeccable/scripts/lib/staleness-deep.mjs index 2c8d6a82f..f3ce76d9f 100644 --- a/.opencode/skills/impeccable/scripts/lib/staleness-deep.mjs +++ b/.opencode/skills/impeccable/scripts/lib/staleness-deep.mjs @@ -244,7 +244,8 @@ const HOOK_MARKER = /skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs/; // * bundle-relative: node ".agents/.../hook.mjs" // * legacy unquoted: node .claude/.../hook.mjs // * guarded (#399): [ ! -f "PATH" ] || node "PATH" (PATH twice, identical) -// * absolute: node "/Users/.../hook.mjs" (user-level installs) +// * absolute (#476): [ ! -f 'PATH' ] || node 'PATH' (single-quoted since +// the shell-injection fix; older installs double-quote) // * github portable: node "$(git rev-parse --show-toplevel)/.../hook.mjs" // A quoted path wins; the guard's two occurrences are identical, so the first // quoted match is the path. Otherwise fall back to the whitespace/metachar- @@ -255,6 +256,12 @@ function hookScriptTokenFrom(command) { if (!HOOK_MARKER.test(str)) return null; const quoted = str.match(/"([^"]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)"/); if (quoted) return quoted[1]; + // A path containing an apostrophe serializes as '\'' inside single quotes; + // no regex reassembles that, and the bare fallback would misread a fragment + // of it, so return null: the caller never asserts on a path it can't parse. + if (str.includes("'\\''")) return null; + const singleQuoted = str.match(/'([^']*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)'/); + if (singleQuoted) return singleQuoted[1]; const bare = str.match(/([^\s"'|&;()]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)/); return bare ? bare[1] : null; } diff --git a/.opencode/skills/impeccable/scripts/live.mjs b/.opencode/skills/impeccable/scripts/live.mjs index b04d98f50..7738c3f02 100644 --- a/.opencode/skills/impeccable/scripts/live.mjs +++ b/.opencode/skills/impeccable/scripts/live.mjs @@ -17,7 +17,7 @@ * node live.mjs --help */ -import { execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -316,11 +316,17 @@ function globToRegex(pattern) { function runScript(name, args, options = {}) { const scriptPath = path.join(__dirname, name); - const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`; try { - return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 }); + // argv form, never a shell: string interpolation into double quotes would + // let a `"` or `$(...)` in any future caller's arg escape into the shell + // (issue #476). + return execFileSync(process.execPath, [scriptPath, ...args], { + encoding: 'utf-8', + cwd: options.cwd || process.cwd(), + timeout: 15_000, + }); } catch (err) { - // execSync throws on non-zero exit; return stdout if any + // execFileSync throws on non-zero exit; return stdout if any return err.stdout || err.message || ''; } } diff --git a/.pi/skills/impeccable/scripts/hook-lib.mjs b/.pi/skills/impeccable/scripts/hook-lib.mjs index b874985a6..9170aa696 100644 --- a/.pi/skills/impeccable/scripts/hook-lib.mjs +++ b/.pi/skills/impeccable/scripts/hook-lib.mjs @@ -1112,7 +1112,19 @@ function formatFindingIgnoreCommand(finding) { function quoteCommandArg(value) { const text = String(value || '').trim(); if (/^[A-Za-z0-9._:-]+$/.test(text)) return text; - return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + // The suggestion is meant to be run on this same machine, so quote for its + // shell. POSIX /bin/sh still expands $(...), backticks, and ${} inside + // double quotes, and these values come from scanned file content (a + // font-family name) or a file path, so untrusted input must be + // single-quoted (issue #476). Windows cmd.exe performs no such command + // substitution, but it treats a single quote as a literal character rather + // than a grouping delimiter, so a value or path containing spaces has to + // stay double-quoted there (Greptile #533). Keep the pre-existing + // double-quote escaping on Windows so that path's behavior is unchanged. + if (process.platform === 'win32') { + return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + } + return `'${text.replace(/'/g, `'\\''`)}'`; } function relativize(filePath, cwd) { diff --git a/.pi/skills/impeccable/scripts/lib/is-generated.mjs b/.pi/skills/impeccable/scripts/lib/is-generated.mjs index 165e1ca80..5e5948ad8 100644 --- a/.pi/skills/impeccable/scripts/lib/is-generated.mjs +++ b/.pi/skills/impeccable/scripts/lib/is-generated.mjs @@ -13,7 +13,7 @@ * within the first ~300 characters — catches non-git projects. */ -import { execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; @@ -41,7 +41,10 @@ export function isGeneratedFile(filePath, options = {}) { function isGitIgnored(absPath, cwd) { try { - execSync(`git check-ignore --quiet ${JSON.stringify(absPath)}`, { + // argv form, never a shell: this runs on every file the live-mode source + // walk reaches, so a hostile filename embedding $(...) or backticks must + // not be interpretable (issue #476). JSON.stringify is not shell quoting. + execFileSync('git', ['check-ignore', '--quiet', absPath], { cwd, stdio: 'ignore', }); diff --git a/.pi/skills/impeccable/scripts/lib/staleness-deep.mjs b/.pi/skills/impeccable/scripts/lib/staleness-deep.mjs index 2c8d6a82f..f3ce76d9f 100644 --- a/.pi/skills/impeccable/scripts/lib/staleness-deep.mjs +++ b/.pi/skills/impeccable/scripts/lib/staleness-deep.mjs @@ -244,7 +244,8 @@ const HOOK_MARKER = /skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs/; // * bundle-relative: node ".agents/.../hook.mjs" // * legacy unquoted: node .claude/.../hook.mjs // * guarded (#399): [ ! -f "PATH" ] || node "PATH" (PATH twice, identical) -// * absolute: node "/Users/.../hook.mjs" (user-level installs) +// * absolute (#476): [ ! -f 'PATH' ] || node 'PATH' (single-quoted since +// the shell-injection fix; older installs double-quote) // * github portable: node "$(git rev-parse --show-toplevel)/.../hook.mjs" // A quoted path wins; the guard's two occurrences are identical, so the first // quoted match is the path. Otherwise fall back to the whitespace/metachar- @@ -255,6 +256,12 @@ function hookScriptTokenFrom(command) { if (!HOOK_MARKER.test(str)) return null; const quoted = str.match(/"([^"]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)"/); if (quoted) return quoted[1]; + // A path containing an apostrophe serializes as '\'' inside single quotes; + // no regex reassembles that, and the bare fallback would misread a fragment + // of it, so return null: the caller never asserts on a path it can't parse. + if (str.includes("'\\''")) return null; + const singleQuoted = str.match(/'([^']*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)'/); + if (singleQuoted) return singleQuoted[1]; const bare = str.match(/([^\s"'|&;()]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)/); return bare ? bare[1] : null; } diff --git a/.pi/skills/impeccable/scripts/live.mjs b/.pi/skills/impeccable/scripts/live.mjs index b04d98f50..7738c3f02 100644 --- a/.pi/skills/impeccable/scripts/live.mjs +++ b/.pi/skills/impeccable/scripts/live.mjs @@ -17,7 +17,7 @@ * node live.mjs --help */ -import { execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -316,11 +316,17 @@ function globToRegex(pattern) { function runScript(name, args, options = {}) { const scriptPath = path.join(__dirname, name); - const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`; try { - return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 }); + // argv form, never a shell: string interpolation into double quotes would + // let a `"` or `$(...)` in any future caller's arg escape into the shell + // (issue #476). + return execFileSync(process.execPath, [scriptPath, ...args], { + encoding: 'utf-8', + cwd: options.cwd || process.cwd(), + timeout: 15_000, + }); } catch (err) { - // execSync throws on non-zero exit; return stdout if any + // execFileSync throws on non-zero exit; return stdout if any return err.stdout || err.message || ''; } } diff --git a/.qoder/skills/impeccable/scripts/hook-lib.mjs b/.qoder/skills/impeccable/scripts/hook-lib.mjs index b874985a6..9170aa696 100644 --- a/.qoder/skills/impeccable/scripts/hook-lib.mjs +++ b/.qoder/skills/impeccable/scripts/hook-lib.mjs @@ -1112,7 +1112,19 @@ function formatFindingIgnoreCommand(finding) { function quoteCommandArg(value) { const text = String(value || '').trim(); if (/^[A-Za-z0-9._:-]+$/.test(text)) return text; - return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + // The suggestion is meant to be run on this same machine, so quote for its + // shell. POSIX /bin/sh still expands $(...), backticks, and ${} inside + // double quotes, and these values come from scanned file content (a + // font-family name) or a file path, so untrusted input must be + // single-quoted (issue #476). Windows cmd.exe performs no such command + // substitution, but it treats a single quote as a literal character rather + // than a grouping delimiter, so a value or path containing spaces has to + // stay double-quoted there (Greptile #533). Keep the pre-existing + // double-quote escaping on Windows so that path's behavior is unchanged. + if (process.platform === 'win32') { + return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + } + return `'${text.replace(/'/g, `'\\''`)}'`; } function relativize(filePath, cwd) { diff --git a/.qoder/skills/impeccable/scripts/lib/is-generated.mjs b/.qoder/skills/impeccable/scripts/lib/is-generated.mjs index 165e1ca80..5e5948ad8 100644 --- a/.qoder/skills/impeccable/scripts/lib/is-generated.mjs +++ b/.qoder/skills/impeccable/scripts/lib/is-generated.mjs @@ -13,7 +13,7 @@ * within the first ~300 characters — catches non-git projects. */ -import { execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; @@ -41,7 +41,10 @@ export function isGeneratedFile(filePath, options = {}) { function isGitIgnored(absPath, cwd) { try { - execSync(`git check-ignore --quiet ${JSON.stringify(absPath)}`, { + // argv form, never a shell: this runs on every file the live-mode source + // walk reaches, so a hostile filename embedding $(...) or backticks must + // not be interpretable (issue #476). JSON.stringify is not shell quoting. + execFileSync('git', ['check-ignore', '--quiet', absPath], { cwd, stdio: 'ignore', }); diff --git a/.qoder/skills/impeccable/scripts/lib/staleness-deep.mjs b/.qoder/skills/impeccable/scripts/lib/staleness-deep.mjs index 2c8d6a82f..f3ce76d9f 100644 --- a/.qoder/skills/impeccable/scripts/lib/staleness-deep.mjs +++ b/.qoder/skills/impeccable/scripts/lib/staleness-deep.mjs @@ -244,7 +244,8 @@ const HOOK_MARKER = /skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs/; // * bundle-relative: node ".agents/.../hook.mjs" // * legacy unquoted: node .claude/.../hook.mjs // * guarded (#399): [ ! -f "PATH" ] || node "PATH" (PATH twice, identical) -// * absolute: node "/Users/.../hook.mjs" (user-level installs) +// * absolute (#476): [ ! -f 'PATH' ] || node 'PATH' (single-quoted since +// the shell-injection fix; older installs double-quote) // * github portable: node "$(git rev-parse --show-toplevel)/.../hook.mjs" // A quoted path wins; the guard's two occurrences are identical, so the first // quoted match is the path. Otherwise fall back to the whitespace/metachar- @@ -255,6 +256,12 @@ function hookScriptTokenFrom(command) { if (!HOOK_MARKER.test(str)) return null; const quoted = str.match(/"([^"]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)"/); if (quoted) return quoted[1]; + // A path containing an apostrophe serializes as '\'' inside single quotes; + // no regex reassembles that, and the bare fallback would misread a fragment + // of it, so return null: the caller never asserts on a path it can't parse. + if (str.includes("'\\''")) return null; + const singleQuoted = str.match(/'([^']*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)'/); + if (singleQuoted) return singleQuoted[1]; const bare = str.match(/([^\s"'|&;()]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)/); return bare ? bare[1] : null; } diff --git a/.qoder/skills/impeccable/scripts/live.mjs b/.qoder/skills/impeccable/scripts/live.mjs index b04d98f50..7738c3f02 100644 --- a/.qoder/skills/impeccable/scripts/live.mjs +++ b/.qoder/skills/impeccable/scripts/live.mjs @@ -17,7 +17,7 @@ * node live.mjs --help */ -import { execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -316,11 +316,17 @@ function globToRegex(pattern) { function runScript(name, args, options = {}) { const scriptPath = path.join(__dirname, name); - const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`; try { - return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 }); + // argv form, never a shell: string interpolation into double quotes would + // let a `"` or `$(...)` in any future caller's arg escape into the shell + // (issue #476). + return execFileSync(process.execPath, [scriptPath, ...args], { + encoding: 'utf-8', + cwd: options.cwd || process.cwd(), + timeout: 15_000, + }); } catch (err) { - // execSync throws on non-zero exit; return stdout if any + // execFileSync throws on non-zero exit; return stdout if any return err.stdout || err.message || ''; } } diff --git a/.rovodev/skills/impeccable/scripts/hook-lib.mjs b/.rovodev/skills/impeccable/scripts/hook-lib.mjs index b874985a6..9170aa696 100644 --- a/.rovodev/skills/impeccable/scripts/hook-lib.mjs +++ b/.rovodev/skills/impeccable/scripts/hook-lib.mjs @@ -1112,7 +1112,19 @@ function formatFindingIgnoreCommand(finding) { function quoteCommandArg(value) { const text = String(value || '').trim(); if (/^[A-Za-z0-9._:-]+$/.test(text)) return text; - return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + // The suggestion is meant to be run on this same machine, so quote for its + // shell. POSIX /bin/sh still expands $(...), backticks, and ${} inside + // double quotes, and these values come from scanned file content (a + // font-family name) or a file path, so untrusted input must be + // single-quoted (issue #476). Windows cmd.exe performs no such command + // substitution, but it treats a single quote as a literal character rather + // than a grouping delimiter, so a value or path containing spaces has to + // stay double-quoted there (Greptile #533). Keep the pre-existing + // double-quote escaping on Windows so that path's behavior is unchanged. + if (process.platform === 'win32') { + return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + } + return `'${text.replace(/'/g, `'\\''`)}'`; } function relativize(filePath, cwd) { diff --git a/.rovodev/skills/impeccable/scripts/lib/is-generated.mjs b/.rovodev/skills/impeccable/scripts/lib/is-generated.mjs index 165e1ca80..5e5948ad8 100644 --- a/.rovodev/skills/impeccable/scripts/lib/is-generated.mjs +++ b/.rovodev/skills/impeccable/scripts/lib/is-generated.mjs @@ -13,7 +13,7 @@ * within the first ~300 characters — catches non-git projects. */ -import { execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; @@ -41,7 +41,10 @@ export function isGeneratedFile(filePath, options = {}) { function isGitIgnored(absPath, cwd) { try { - execSync(`git check-ignore --quiet ${JSON.stringify(absPath)}`, { + // argv form, never a shell: this runs on every file the live-mode source + // walk reaches, so a hostile filename embedding $(...) or backticks must + // not be interpretable (issue #476). JSON.stringify is not shell quoting. + execFileSync('git', ['check-ignore', '--quiet', absPath], { cwd, stdio: 'ignore', }); diff --git a/.rovodev/skills/impeccable/scripts/lib/staleness-deep.mjs b/.rovodev/skills/impeccable/scripts/lib/staleness-deep.mjs index 2c8d6a82f..f3ce76d9f 100644 --- a/.rovodev/skills/impeccable/scripts/lib/staleness-deep.mjs +++ b/.rovodev/skills/impeccable/scripts/lib/staleness-deep.mjs @@ -244,7 +244,8 @@ const HOOK_MARKER = /skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs/; // * bundle-relative: node ".agents/.../hook.mjs" // * legacy unquoted: node .claude/.../hook.mjs // * guarded (#399): [ ! -f "PATH" ] || node "PATH" (PATH twice, identical) -// * absolute: node "/Users/.../hook.mjs" (user-level installs) +// * absolute (#476): [ ! -f 'PATH' ] || node 'PATH' (single-quoted since +// the shell-injection fix; older installs double-quote) // * github portable: node "$(git rev-parse --show-toplevel)/.../hook.mjs" // A quoted path wins; the guard's two occurrences are identical, so the first // quoted match is the path. Otherwise fall back to the whitespace/metachar- @@ -255,6 +256,12 @@ function hookScriptTokenFrom(command) { if (!HOOK_MARKER.test(str)) return null; const quoted = str.match(/"([^"]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)"/); if (quoted) return quoted[1]; + // A path containing an apostrophe serializes as '\'' inside single quotes; + // no regex reassembles that, and the bare fallback would misread a fragment + // of it, so return null: the caller never asserts on a path it can't parse. + if (str.includes("'\\''")) return null; + const singleQuoted = str.match(/'([^']*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)'/); + if (singleQuoted) return singleQuoted[1]; const bare = str.match(/([^\s"'|&;()]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)/); return bare ? bare[1] : null; } diff --git a/.rovodev/skills/impeccable/scripts/live.mjs b/.rovodev/skills/impeccable/scripts/live.mjs index b04d98f50..7738c3f02 100644 --- a/.rovodev/skills/impeccable/scripts/live.mjs +++ b/.rovodev/skills/impeccable/scripts/live.mjs @@ -17,7 +17,7 @@ * node live.mjs --help */ -import { execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -316,11 +316,17 @@ function globToRegex(pattern) { function runScript(name, args, options = {}) { const scriptPath = path.join(__dirname, name); - const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`; try { - return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 }); + // argv form, never a shell: string interpolation into double quotes would + // let a `"` or `$(...)` in any future caller's arg escape into the shell + // (issue #476). + return execFileSync(process.execPath, [scriptPath, ...args], { + encoding: 'utf-8', + cwd: options.cwd || process.cwd(), + timeout: 15_000, + }); } catch (err) { - // execSync throws on non-zero exit; return stdout if any + // execFileSync throws on non-zero exit; return stdout if any return err.stdout || err.message || ''; } } diff --git a/.trae-cn/skills/impeccable/scripts/hook-lib.mjs b/.trae-cn/skills/impeccable/scripts/hook-lib.mjs index b874985a6..9170aa696 100644 --- a/.trae-cn/skills/impeccable/scripts/hook-lib.mjs +++ b/.trae-cn/skills/impeccable/scripts/hook-lib.mjs @@ -1112,7 +1112,19 @@ function formatFindingIgnoreCommand(finding) { function quoteCommandArg(value) { const text = String(value || '').trim(); if (/^[A-Za-z0-9._:-]+$/.test(text)) return text; - return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + // The suggestion is meant to be run on this same machine, so quote for its + // shell. POSIX /bin/sh still expands $(...), backticks, and ${} inside + // double quotes, and these values come from scanned file content (a + // font-family name) or a file path, so untrusted input must be + // single-quoted (issue #476). Windows cmd.exe performs no such command + // substitution, but it treats a single quote as a literal character rather + // than a grouping delimiter, so a value or path containing spaces has to + // stay double-quoted there (Greptile #533). Keep the pre-existing + // double-quote escaping on Windows so that path's behavior is unchanged. + if (process.platform === 'win32') { + return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + } + return `'${text.replace(/'/g, `'\\''`)}'`; } function relativize(filePath, cwd) { diff --git a/.trae-cn/skills/impeccable/scripts/lib/is-generated.mjs b/.trae-cn/skills/impeccable/scripts/lib/is-generated.mjs index 165e1ca80..5e5948ad8 100644 --- a/.trae-cn/skills/impeccable/scripts/lib/is-generated.mjs +++ b/.trae-cn/skills/impeccable/scripts/lib/is-generated.mjs @@ -13,7 +13,7 @@ * within the first ~300 characters — catches non-git projects. */ -import { execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; @@ -41,7 +41,10 @@ export function isGeneratedFile(filePath, options = {}) { function isGitIgnored(absPath, cwd) { try { - execSync(`git check-ignore --quiet ${JSON.stringify(absPath)}`, { + // argv form, never a shell: this runs on every file the live-mode source + // walk reaches, so a hostile filename embedding $(...) or backticks must + // not be interpretable (issue #476). JSON.stringify is not shell quoting. + execFileSync('git', ['check-ignore', '--quiet', absPath], { cwd, stdio: 'ignore', }); diff --git a/.trae-cn/skills/impeccable/scripts/lib/staleness-deep.mjs b/.trae-cn/skills/impeccable/scripts/lib/staleness-deep.mjs index 2c8d6a82f..f3ce76d9f 100644 --- a/.trae-cn/skills/impeccable/scripts/lib/staleness-deep.mjs +++ b/.trae-cn/skills/impeccable/scripts/lib/staleness-deep.mjs @@ -244,7 +244,8 @@ const HOOK_MARKER = /skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs/; // * bundle-relative: node ".agents/.../hook.mjs" // * legacy unquoted: node .claude/.../hook.mjs // * guarded (#399): [ ! -f "PATH" ] || node "PATH" (PATH twice, identical) -// * absolute: node "/Users/.../hook.mjs" (user-level installs) +// * absolute (#476): [ ! -f 'PATH' ] || node 'PATH' (single-quoted since +// the shell-injection fix; older installs double-quote) // * github portable: node "$(git rev-parse --show-toplevel)/.../hook.mjs" // A quoted path wins; the guard's two occurrences are identical, so the first // quoted match is the path. Otherwise fall back to the whitespace/metachar- @@ -255,6 +256,12 @@ function hookScriptTokenFrom(command) { if (!HOOK_MARKER.test(str)) return null; const quoted = str.match(/"([^"]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)"/); if (quoted) return quoted[1]; + // A path containing an apostrophe serializes as '\'' inside single quotes; + // no regex reassembles that, and the bare fallback would misread a fragment + // of it, so return null: the caller never asserts on a path it can't parse. + if (str.includes("'\\''")) return null; + const singleQuoted = str.match(/'([^']*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)'/); + if (singleQuoted) return singleQuoted[1]; const bare = str.match(/([^\s"'|&;()]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)/); return bare ? bare[1] : null; } diff --git a/.trae-cn/skills/impeccable/scripts/live.mjs b/.trae-cn/skills/impeccable/scripts/live.mjs index b04d98f50..7738c3f02 100644 --- a/.trae-cn/skills/impeccable/scripts/live.mjs +++ b/.trae-cn/skills/impeccable/scripts/live.mjs @@ -17,7 +17,7 @@ * node live.mjs --help */ -import { execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -316,11 +316,17 @@ function globToRegex(pattern) { function runScript(name, args, options = {}) { const scriptPath = path.join(__dirname, name); - const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`; try { - return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 }); + // argv form, never a shell: string interpolation into double quotes would + // let a `"` or `$(...)` in any future caller's arg escape into the shell + // (issue #476). + return execFileSync(process.execPath, [scriptPath, ...args], { + encoding: 'utf-8', + cwd: options.cwd || process.cwd(), + timeout: 15_000, + }); } catch (err) { - // execSync throws on non-zero exit; return stdout if any + // execFileSync throws on non-zero exit; return stdout if any return err.stdout || err.message || ''; } } diff --git a/.trae/skills/impeccable/scripts/hook-lib.mjs b/.trae/skills/impeccable/scripts/hook-lib.mjs index b874985a6..9170aa696 100644 --- a/.trae/skills/impeccable/scripts/hook-lib.mjs +++ b/.trae/skills/impeccable/scripts/hook-lib.mjs @@ -1112,7 +1112,19 @@ function formatFindingIgnoreCommand(finding) { function quoteCommandArg(value) { const text = String(value || '').trim(); if (/^[A-Za-z0-9._:-]+$/.test(text)) return text; - return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + // The suggestion is meant to be run on this same machine, so quote for its + // shell. POSIX /bin/sh still expands $(...), backticks, and ${} inside + // double quotes, and these values come from scanned file content (a + // font-family name) or a file path, so untrusted input must be + // single-quoted (issue #476). Windows cmd.exe performs no such command + // substitution, but it treats a single quote as a literal character rather + // than a grouping delimiter, so a value or path containing spaces has to + // stay double-quoted there (Greptile #533). Keep the pre-existing + // double-quote escaping on Windows so that path's behavior is unchanged. + if (process.platform === 'win32') { + return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + } + return `'${text.replace(/'/g, `'\\''`)}'`; } function relativize(filePath, cwd) { diff --git a/.trae/skills/impeccable/scripts/lib/is-generated.mjs b/.trae/skills/impeccable/scripts/lib/is-generated.mjs index 165e1ca80..5e5948ad8 100644 --- a/.trae/skills/impeccable/scripts/lib/is-generated.mjs +++ b/.trae/skills/impeccable/scripts/lib/is-generated.mjs @@ -13,7 +13,7 @@ * within the first ~300 characters — catches non-git projects. */ -import { execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; @@ -41,7 +41,10 @@ export function isGeneratedFile(filePath, options = {}) { function isGitIgnored(absPath, cwd) { try { - execSync(`git check-ignore --quiet ${JSON.stringify(absPath)}`, { + // argv form, never a shell: this runs on every file the live-mode source + // walk reaches, so a hostile filename embedding $(...) or backticks must + // not be interpretable (issue #476). JSON.stringify is not shell quoting. + execFileSync('git', ['check-ignore', '--quiet', absPath], { cwd, stdio: 'ignore', }); diff --git a/.trae/skills/impeccable/scripts/lib/staleness-deep.mjs b/.trae/skills/impeccable/scripts/lib/staleness-deep.mjs index 2c8d6a82f..f3ce76d9f 100644 --- a/.trae/skills/impeccable/scripts/lib/staleness-deep.mjs +++ b/.trae/skills/impeccable/scripts/lib/staleness-deep.mjs @@ -244,7 +244,8 @@ const HOOK_MARKER = /skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs/; // * bundle-relative: node ".agents/.../hook.mjs" // * legacy unquoted: node .claude/.../hook.mjs // * guarded (#399): [ ! -f "PATH" ] || node "PATH" (PATH twice, identical) -// * absolute: node "/Users/.../hook.mjs" (user-level installs) +// * absolute (#476): [ ! -f 'PATH' ] || node 'PATH' (single-quoted since +// the shell-injection fix; older installs double-quote) // * github portable: node "$(git rev-parse --show-toplevel)/.../hook.mjs" // A quoted path wins; the guard's two occurrences are identical, so the first // quoted match is the path. Otherwise fall back to the whitespace/metachar- @@ -255,6 +256,12 @@ function hookScriptTokenFrom(command) { if (!HOOK_MARKER.test(str)) return null; const quoted = str.match(/"([^"]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)"/); if (quoted) return quoted[1]; + // A path containing an apostrophe serializes as '\'' inside single quotes; + // no regex reassembles that, and the bare fallback would misread a fragment + // of it, so return null: the caller never asserts on a path it can't parse. + if (str.includes("'\\''")) return null; + const singleQuoted = str.match(/'([^']*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)'/); + if (singleQuoted) return singleQuoted[1]; const bare = str.match(/([^\s"'|&;()]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)/); return bare ? bare[1] : null; } diff --git a/.trae/skills/impeccable/scripts/live.mjs b/.trae/skills/impeccable/scripts/live.mjs index b04d98f50..7738c3f02 100644 --- a/.trae/skills/impeccable/scripts/live.mjs +++ b/.trae/skills/impeccable/scripts/live.mjs @@ -17,7 +17,7 @@ * node live.mjs --help */ -import { execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -316,11 +316,17 @@ function globToRegex(pattern) { function runScript(name, args, options = {}) { const scriptPath = path.join(__dirname, name); - const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`; try { - return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 }); + // argv form, never a shell: string interpolation into double quotes would + // let a `"` or `$(...)` in any future caller's arg escape into the shell + // (issue #476). + return execFileSync(process.execPath, [scriptPath, ...args], { + encoding: 'utf-8', + cwd: options.cwd || process.cwd(), + timeout: 15_000, + }); } catch (err) { - // execSync throws on non-zero exit; return stdout if any + // execFileSync throws on non-zero exit; return stdout if any return err.stdout || err.message || ''; } } diff --git a/.vibe/skills/impeccable/scripts/hook-lib.mjs b/.vibe/skills/impeccable/scripts/hook-lib.mjs index b874985a6..9170aa696 100644 --- a/.vibe/skills/impeccable/scripts/hook-lib.mjs +++ b/.vibe/skills/impeccable/scripts/hook-lib.mjs @@ -1112,7 +1112,19 @@ function formatFindingIgnoreCommand(finding) { function quoteCommandArg(value) { const text = String(value || '').trim(); if (/^[A-Za-z0-9._:-]+$/.test(text)) return text; - return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + // The suggestion is meant to be run on this same machine, so quote for its + // shell. POSIX /bin/sh still expands $(...), backticks, and ${} inside + // double quotes, and these values come from scanned file content (a + // font-family name) or a file path, so untrusted input must be + // single-quoted (issue #476). Windows cmd.exe performs no such command + // substitution, but it treats a single quote as a literal character rather + // than a grouping delimiter, so a value or path containing spaces has to + // stay double-quoted there (Greptile #533). Keep the pre-existing + // double-quote escaping on Windows so that path's behavior is unchanged. + if (process.platform === 'win32') { + return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + } + return `'${text.replace(/'/g, `'\\''`)}'`; } function relativize(filePath, cwd) { diff --git a/.vibe/skills/impeccable/scripts/lib/is-generated.mjs b/.vibe/skills/impeccable/scripts/lib/is-generated.mjs index 165e1ca80..5e5948ad8 100644 --- a/.vibe/skills/impeccable/scripts/lib/is-generated.mjs +++ b/.vibe/skills/impeccable/scripts/lib/is-generated.mjs @@ -13,7 +13,7 @@ * within the first ~300 characters — catches non-git projects. */ -import { execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; @@ -41,7 +41,10 @@ export function isGeneratedFile(filePath, options = {}) { function isGitIgnored(absPath, cwd) { try { - execSync(`git check-ignore --quiet ${JSON.stringify(absPath)}`, { + // argv form, never a shell: this runs on every file the live-mode source + // walk reaches, so a hostile filename embedding $(...) or backticks must + // not be interpretable (issue #476). JSON.stringify is not shell quoting. + execFileSync('git', ['check-ignore', '--quiet', absPath], { cwd, stdio: 'ignore', }); diff --git a/.vibe/skills/impeccable/scripts/lib/staleness-deep.mjs b/.vibe/skills/impeccable/scripts/lib/staleness-deep.mjs index 2c8d6a82f..f3ce76d9f 100644 --- a/.vibe/skills/impeccable/scripts/lib/staleness-deep.mjs +++ b/.vibe/skills/impeccable/scripts/lib/staleness-deep.mjs @@ -244,7 +244,8 @@ const HOOK_MARKER = /skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs/; // * bundle-relative: node ".agents/.../hook.mjs" // * legacy unquoted: node .claude/.../hook.mjs // * guarded (#399): [ ! -f "PATH" ] || node "PATH" (PATH twice, identical) -// * absolute: node "/Users/.../hook.mjs" (user-level installs) +// * absolute (#476): [ ! -f 'PATH' ] || node 'PATH' (single-quoted since +// the shell-injection fix; older installs double-quote) // * github portable: node "$(git rev-parse --show-toplevel)/.../hook.mjs" // A quoted path wins; the guard's two occurrences are identical, so the first // quoted match is the path. Otherwise fall back to the whitespace/metachar- @@ -255,6 +256,12 @@ function hookScriptTokenFrom(command) { if (!HOOK_MARKER.test(str)) return null; const quoted = str.match(/"([^"]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)"/); if (quoted) return quoted[1]; + // A path containing an apostrophe serializes as '\'' inside single quotes; + // no regex reassembles that, and the bare fallback would misread a fragment + // of it, so return null: the caller never asserts on a path it can't parse. + if (str.includes("'\\''")) return null; + const singleQuoted = str.match(/'([^']*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)'/); + if (singleQuoted) return singleQuoted[1]; const bare = str.match(/([^\s"'|&;()]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)/); return bare ? bare[1] : null; } diff --git a/.vibe/skills/impeccable/scripts/live.mjs b/.vibe/skills/impeccable/scripts/live.mjs index b04d98f50..7738c3f02 100644 --- a/.vibe/skills/impeccable/scripts/live.mjs +++ b/.vibe/skills/impeccable/scripts/live.mjs @@ -17,7 +17,7 @@ * node live.mjs --help */ -import { execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -316,11 +316,17 @@ function globToRegex(pattern) { function runScript(name, args, options = {}) { const scriptPath = path.join(__dirname, name); - const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`; try { - return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 }); + // argv form, never a shell: string interpolation into double quotes would + // let a `"` or `$(...)` in any future caller's arg escape into the shell + // (issue #476). + return execFileSync(process.execPath, [scriptPath, ...args], { + encoding: 'utf-8', + cwd: options.cwd || process.cwd(), + timeout: 15_000, + }); } catch (err) { - // execSync throws on non-zero exit; return stdout if any + // execFileSync throws on non-zero exit; return stdout if any return err.stdout || err.message || ''; } } diff --git a/plugin/skills/impeccable/scripts/hook-lib.mjs b/plugin/skills/impeccable/scripts/hook-lib.mjs index b874985a6..9170aa696 100644 --- a/plugin/skills/impeccable/scripts/hook-lib.mjs +++ b/plugin/skills/impeccable/scripts/hook-lib.mjs @@ -1112,7 +1112,19 @@ function formatFindingIgnoreCommand(finding) { function quoteCommandArg(value) { const text = String(value || '').trim(); if (/^[A-Za-z0-9._:-]+$/.test(text)) return text; - return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + // The suggestion is meant to be run on this same machine, so quote for its + // shell. POSIX /bin/sh still expands $(...), backticks, and ${} inside + // double quotes, and these values come from scanned file content (a + // font-family name) or a file path, so untrusted input must be + // single-quoted (issue #476). Windows cmd.exe performs no such command + // substitution, but it treats a single quote as a literal character rather + // than a grouping delimiter, so a value or path containing spaces has to + // stay double-quoted there (Greptile #533). Keep the pre-existing + // double-quote escaping on Windows so that path's behavior is unchanged. + if (process.platform === 'win32') { + return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + } + return `'${text.replace(/'/g, `'\\''`)}'`; } function relativize(filePath, cwd) { diff --git a/plugin/skills/impeccable/scripts/lib/is-generated.mjs b/plugin/skills/impeccable/scripts/lib/is-generated.mjs index 165e1ca80..5e5948ad8 100644 --- a/plugin/skills/impeccable/scripts/lib/is-generated.mjs +++ b/plugin/skills/impeccable/scripts/lib/is-generated.mjs @@ -13,7 +13,7 @@ * within the first ~300 characters — catches non-git projects. */ -import { execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; @@ -41,7 +41,10 @@ export function isGeneratedFile(filePath, options = {}) { function isGitIgnored(absPath, cwd) { try { - execSync(`git check-ignore --quiet ${JSON.stringify(absPath)}`, { + // argv form, never a shell: this runs on every file the live-mode source + // walk reaches, so a hostile filename embedding $(...) or backticks must + // not be interpretable (issue #476). JSON.stringify is not shell quoting. + execFileSync('git', ['check-ignore', '--quiet', absPath], { cwd, stdio: 'ignore', }); diff --git a/plugin/skills/impeccable/scripts/lib/staleness-deep.mjs b/plugin/skills/impeccable/scripts/lib/staleness-deep.mjs index 2c8d6a82f..f3ce76d9f 100644 --- a/plugin/skills/impeccable/scripts/lib/staleness-deep.mjs +++ b/plugin/skills/impeccable/scripts/lib/staleness-deep.mjs @@ -244,7 +244,8 @@ const HOOK_MARKER = /skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs/; // * bundle-relative: node ".agents/.../hook.mjs" // * legacy unquoted: node .claude/.../hook.mjs // * guarded (#399): [ ! -f "PATH" ] || node "PATH" (PATH twice, identical) -// * absolute: node "/Users/.../hook.mjs" (user-level installs) +// * absolute (#476): [ ! -f 'PATH' ] || node 'PATH' (single-quoted since +// the shell-injection fix; older installs double-quote) // * github portable: node "$(git rev-parse --show-toplevel)/.../hook.mjs" // A quoted path wins; the guard's two occurrences are identical, so the first // quoted match is the path. Otherwise fall back to the whitespace/metachar- @@ -255,6 +256,12 @@ function hookScriptTokenFrom(command) { if (!HOOK_MARKER.test(str)) return null; const quoted = str.match(/"([^"]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)"/); if (quoted) return quoted[1]; + // A path containing an apostrophe serializes as '\'' inside single quotes; + // no regex reassembles that, and the bare fallback would misread a fragment + // of it, so return null: the caller never asserts on a path it can't parse. + if (str.includes("'\\''")) return null; + const singleQuoted = str.match(/'([^']*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)'/); + if (singleQuoted) return singleQuoted[1]; const bare = str.match(/([^\s"'|&;()]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)/); return bare ? bare[1] : null; } diff --git a/plugin/skills/impeccable/scripts/live.mjs b/plugin/skills/impeccable/scripts/live.mjs index b04d98f50..7738c3f02 100644 --- a/plugin/skills/impeccable/scripts/live.mjs +++ b/plugin/skills/impeccable/scripts/live.mjs @@ -17,7 +17,7 @@ * node live.mjs --help */ -import { execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -316,11 +316,17 @@ function globToRegex(pattern) { function runScript(name, args, options = {}) { const scriptPath = path.join(__dirname, name); - const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`; try { - return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 }); + // argv form, never a shell: string interpolation into double quotes would + // let a `"` or `$(...)` in any future caller's arg escape into the shell + // (issue #476). + return execFileSync(process.execPath, [scriptPath, ...args], { + encoding: 'utf-8', + cwd: options.cwd || process.cwd(), + timeout: 15_000, + }); } catch (err) { - // execSync throws on non-zero exit; return stdout if any + // execFileSync throws on non-zero exit; return stdout if any return err.stdout || err.message || ''; } }