From 01aa866d448fc814d35e728332c3b3795c43f4de Mon Sep 17 00:00:00 2001 From: Sophio Japharidze Date: Thu, 2 Jul 2026 17:15:03 +0200 Subject: [PATCH] CLI-497 wrap hook command in quotes --- src/cli/commands/integrate/_common/hooks.ts | 29 ++- src/cli/commands/integrate/claude/hooks.ts | 14 +- tests/integration/harness/platform.ts | 22 ++- .../specs/integrate/claude.test.ts | 80 ++++++-- .../integration/specs/integrate/codex.test.ts | 34 +++- .../specs/self-update/post-update.test.ts | 10 +- .../commands/integrate/_common/hooks.test.ts | 185 +++++++++++++++++- .../commands/integrate/claude/hooks.test.ts | 41 +++- .../commands/integrate/cursor/hooks.test.ts | 3 +- 9 files changed, 375 insertions(+), 43 deletions(-) diff --git a/src/cli/commands/integrate/_common/hooks.ts b/src/cli/commands/integrate/_common/hooks.ts index b89dae5dc..ac18a804e 100644 --- a/src/cli/commands/integrate/_common/hooks.ts +++ b/src/cli/commands/integrate/_common/hooks.ts @@ -129,6 +129,22 @@ export function shellQuotePowerShell(value: string): string { return "'" + value.replaceAll("'", POWERSHELL_EMBEDDED_SINGLE_QUOTE) + "'"; } +/** + * Quote a path for PowerShell's `-File` argument on Windows so it stays a single + * argument when it contains **spaces**. Double quotes are used because Windows + * paths cannot contain `"` (so no escaping is needed) and both cmd.exe and + * PowerShell honor them. + * + * The guarantee is space-safety only. It does NOT neutralize shell-level + * expansion of a path segment literally named e.g. `%TEMP%`: when the agent + * launches the stored command through cmd.exe, cmd expands `%VAR%` even inside + * double quotes, and no argument-level quoting (single or double) prevents that. + * Such directory names are rare, and this mirrors the pre-existing behavior. + */ +export function quoteWindowsHookScriptPath(path: string): string { + return `"${path}"`; +} + export type SqaaHookSubcommand = 'claude-post-tool-use' | 'codex-post-tool-use'; export function formatSqaaHookCliArgsUnix( @@ -172,9 +188,12 @@ export function resolveAgentHookScriptPath( } /** - * Hook `command` string: `powershell -NoProfile -ExecutionPolicy Bypass -File ` on Windows, raw - * path on Unix. Absolute path for global scope, relative path (portable when - * the project is moved) for project scope. + * Hook `command` string: `powershell -NoProfile -ExecutionPolicy Bypass -File ""` on + * Windows, single-quoted path on Unix. The path is quoted so it stays a single + * argument when the agent runs the command through a shell, even when the + * project root or `$HOME` contains spaces or (on Unix) other shell + * metacharacters (`$`, backticks, apostrophes, globs). Absolute path for global + * scope, relative path (portable when the project is moved) for project scope. */ export function resolveAgentHookCommand( context: IntegrationContext, @@ -187,8 +206,8 @@ export function resolveAgentHookCommand( context.scope === 'global' ? join(context.targetRoot, relativePath) : relativePath; return process.platform === 'win32' - ? `powershell -NoProfile -ExecutionPolicy Bypass -File ${commandPath.replaceAll('\\', '/')}` - : commandPath; + ? `powershell -NoProfile -ExecutionPolicy Bypass -File ${quoteWindowsHookScriptPath(commandPath.replaceAll('\\', '/'))}` + : shellQuoteBash(commandPath); } export function createAgentHookEntry( diff --git a/src/cli/commands/integrate/claude/hooks.ts b/src/cli/commands/integrate/claude/hooks.ts index 8dc026ea1..b0f7e3a6d 100644 --- a/src/cli/commands/integrate/claude/hooks.ts +++ b/src/cli/commands/integrate/claude/hooks.ts @@ -26,7 +26,13 @@ import { basename, dirname, join } from 'node:path'; import logger from '../../../../lib/logger'; import { warn } from '../../../../ui'; -import { readOrInitJson, SONAR_SECRETS_MARKER, writeHookScript } from '../_common/hooks'; +import { + quoteWindowsHookScriptPath, + readOrInitJson, + shellQuoteBash, + SONAR_SECRETS_MARKER, + writeHookScript, +} from '../_common/hooks'; import { getSecretPreToolTemplateUnix, getSecretPreToolTemplateWindows, @@ -115,9 +121,11 @@ async function installHook(params: HookInstallParams): Promise { // Global: absolute path; project: relative to installDir (portable when project is moved) const relativePath = join(configDir, HOOKS_DIR, `${scriptPath}${scriptExt}`); const commandPath = scope === 'global' ? fullScriptPath : relativePath; + // Quote the path so it survives the agent's shell when it contains spaces or + // (on Unix) other shell metacharacters. Mirrors resolveAgentHookCommand. const command = isWindows - ? `powershell -NoProfile -ExecutionPolicy Bypass -File ${commandPath.replaceAll('\\', '/')}` - : commandPath; + ? `powershell -NoProfile -ExecutionPolicy Bypass -File ${quoteWindowsHookScriptPath(commandPath.replaceAll('\\', '/'))}` + : shellQuoteBash(commandPath); // Marker derived from first path segment (e.g. 'sonar-secrets' from 'sonar-secrets/build-scripts/pretool-secrets') const marker = scriptPath.split('/')[0]; diff --git a/tests/integration/harness/platform.ts b/tests/integration/harness/platform.ts index 1037e7f35..23eb5d043 100644 --- a/tests/integration/harness/platform.ts +++ b/tests/integration/harness/platform.ts @@ -52,13 +52,27 @@ export function hookScriptName(name: string): string { /** * Extract the script path from a hook command string. - * On Windows commands are wrapped as `powershell -NoProfile -ExecutionPolicy Bypass -File `; - * this strips that prefix. Always normalizes to forward slashes. + * On Windows commands are wrapped as `powershell -NoProfile -ExecutionPolicy Bypass -File ""`; + * this strips that prefix. The path is quoted (single quotes on Unix, double + * quotes on Windows) so spaces survive the agent's shell; this unquotes it. + * Always normalizes to forward slashes. */ export function hookScriptPath(command: string): string { const powershellPrefix = 'powershell -NoProfile -ExecutionPolicy Bypass -File '; - const path = command.startsWith(powershellPrefix) + const raw = command.startsWith(powershellPrefix) ? command.slice(powershellPrefix.length) : command; - return normalizePath(path); + return normalizePath(unquoteHookScriptPath(raw)); +} + +/** Reverse the shell quoting applied by resolveAgentHookCommand. */ +function unquoteHookScriptPath(value: string): string { + if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) { + return value.slice(1, -1); + } + if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) { + // Bash embeds a literal single quote as '\'' — collapse it back. + return value.slice(1, -1).replaceAll(String.raw`'\''`, "'"); + } + return value; } diff --git a/tests/integration/specs/integrate/claude.test.ts b/tests/integration/specs/integrate/claude.test.ts index 6777c673c..f1d7f6fd1 100644 --- a/tests/integration/specs/integrate/claude.test.ts +++ b/tests/integration/specs/integrate/claude.test.ts @@ -506,6 +506,44 @@ describe('integrate claude', () => { { timeout: 30000 }, ); + it( + 'quotes the hook command so it survives a project directory containing a space', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken('test-token') + .withProject('my-project') + .start(); + harness.withAuth(server.baseUrl(), 'test-token'); + harness.state().withSecretsBinaryInstalled(); + + const spacedDir = harness.cwd.dir('dir with space', 'myproj'); + spacedDir.writeFile( + 'sonar-project.properties', + [`sonar.host.url=${server.baseUrl()}`, 'sonar.projectKey=my-project'].join('\n'), + ); + + const result = await harness.run('integrate claude --non-interactive', { + cwd: spacedDir.path, + }); + + expect(result.exitCode).toBe(0); + const command = String( + spacedDir.file('.claude', 'settings.json').asJson().hooks.PreToolUse[0].hooks[0].command, + ); + // Project scope emits a relative, fully-quoted path (double quotes on + // Windows, single quotes on Unix) — deterministic regardless of the + // spaced project directory, so assert the exact command. + const scriptRel = '.claude/hooks/sonar-secrets/build-scripts/pretool-secrets'; + expect(command).toBe( + IS_WINDOWS + ? `powershell -NoProfile -ExecutionPolicy Bypass -File "${scriptRel}.ps1"` + : `'${scriptRel}.sh'`, + ); + }, + { timeout: 30000 }, + ); + it( 'pretool-secrets script exists and is executable after integration', async () => { @@ -1501,17 +1539,18 @@ describe.skipIf(IS_WINDOWS)('integrate claude — legacy state without agentExte const preToolEntry = settings.hooks?.PreToolUse?.[0]; const promptEntry = settings.hooks?.UserPromptSubmit?.[0]; expect(preToolEntry?.matcher).toBe('Read'); - expect(preToolEntry?.hooks?.[0]).toEqual({ - type: 'command', - command: '.claude/hooks/sonar-secrets/build-scripts/pretool-secrets.sh', - timeout: 60, - }); + expect(preToolEntry?.hooks?.[0]?.type).toBe('command'); + expect(preToolEntry?.hooks?.[0]?.timeout).toBe(60); + // Command is shell-quoted; compare the unquoted path. + expect(hookScriptPath(String(preToolEntry?.hooks?.[0]?.command))).toBe( + '.claude/hooks/sonar-secrets/build-scripts/pretool-secrets.sh', + ); expect(promptEntry?.matcher).toBe('*'); - expect(promptEntry?.hooks?.[0]).toEqual({ - type: 'command', - command: '.claude/hooks/sonar-secrets/build-scripts/prompt-secrets.sh', - timeout: 60, - }); + expect(promptEntry?.hooks?.[0]?.type).toBe('command'); + expect(promptEntry?.hooks?.[0]?.timeout).toBe(60); + expect(hookScriptPath(String(promptEntry?.hooks?.[0]?.command))).toBe( + '.claude/hooks/sonar-secrets/build-scripts/prompt-secrets.sh', + ); }, { timeout: 30000 }, ); @@ -1714,17 +1753,18 @@ describe.skipIf(IS_WINDOWS)('post-update migration — hook script rewrite on CL const preToolEntry = settings.hooks?.PreToolUse?.[0]; const promptEntry = settings.hooks?.UserPromptSubmit?.[0]; expect(preToolEntry?.matcher).toBe('Read'); - expect(preToolEntry?.hooks?.[0]).toEqual({ - type: 'command', - command: harness.userHome.file(pretoolScriptRel).path, - timeout: 60, - }); + expect(preToolEntry?.hooks?.[0]?.type).toBe('command'); + expect(preToolEntry?.hooks?.[0]?.timeout).toBe(60); + // Command is shell-quoted; compare the unquoted, normalized path. + expect(hookScriptPath(String(preToolEntry?.hooks?.[0]?.command))).toBe( + normalizePath(harness.userHome.file(pretoolScriptRel).path), + ); expect(promptEntry?.matcher).toBe('*'); - expect(promptEntry?.hooks?.[0]).toEqual({ - type: 'command', - command: harness.userHome.file(promptScriptRel).path, - timeout: 60, - }); + expect(promptEntry?.hooks?.[0]?.type).toBe('command'); + expect(promptEntry?.hooks?.[0]?.timeout).toBe(60); + expect(hookScriptPath(String(promptEntry?.hooks?.[0]?.command))).toBe( + normalizePath(harness.userHome.file(promptScriptRel).path), + ); }, { timeout: 30000 }, ); diff --git a/tests/integration/specs/integrate/codex.test.ts b/tests/integration/specs/integrate/codex.test.ts index f3b84b2c4..e04dbeabc 100644 --- a/tests/integration/specs/integrate/codex.test.ts +++ b/tests/integration/specs/integrate/codex.test.ts @@ -28,7 +28,13 @@ import { isAbsolute } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { codexIntegration } from '../../../../src/cli/commands/integrate/codex/declaration'; -import { hookScriptName, hookScriptPath, normalizePath, TestHarness } from '../../harness'; +import { + hookScriptName, + hookScriptPath, + IS_WINDOWS, + normalizePath, + TestHarness, +} from '../../harness'; import { findInstalledFeature } from './state-helpers'; const PROMPT_SCRIPT_DIRS = ['.codex', 'hooks', 'sonar-secrets', 'build-scripts']; @@ -117,6 +123,32 @@ describe('integrate codex', () => { { timeout: 30000 }, ); + it( + 'quotes the hook command so it survives a project directory containing a space', + async () => { + const spacedDir = harness.cwd.dir('dir with space', 'myproj'); + spacedDir.writeFile('sonar-project.properties', 'sonar.projectKey=my-project'); + + const result = await harness.run('integrate codex --non-interactive', { + cwd: spacedDir.path, + }); + + expect(result.exitCode).toBe(0); + const hooks: CodexHooksFile = spacedDir.file(...HOOKS_JSON_DIRS).asJson(); + const command = String(hooks.hooks?.UserPromptSubmit?.[0]?.hooks?.[0]?.command); + // Project scope emits a relative, fully-quoted path (double quotes on + // Windows, single quotes on Unix) — deterministic regardless of the + // spaced project directory, so assert the exact command. + const scriptRel = '.codex/hooks/sonar-secrets/build-scripts/prompt-secrets'; + expect(command).toBe( + IS_WINDOWS + ? `powershell -NoProfile -ExecutionPolicy Bypass -File "${scriptRel}.ps1"` + : `'${scriptRel}.sh'`, + ); + }, + { timeout: 30000 }, + ); + it( 're-running does not duplicate the UserPromptSubmit entry', async () => { diff --git a/tests/integration/specs/self-update/post-update.test.ts b/tests/integration/specs/self-update/post-update.test.ts index 1e1753d26..57f7baecb 100644 --- a/tests/integration/specs/self-update/post-update.test.ts +++ b/tests/integration/specs/self-update/post-update.test.ts @@ -233,12 +233,14 @@ describe('post-update migration', () => { const pretoolScriptRel = `.claude/hooks/sonar-secrets/build-scripts/${hookScriptName('pretool-secrets')}`; const promptScriptRel = `.claude/hooks/sonar-secrets/build-scripts/${hookScriptName('prompt-secrets')}`; const settingsRel = '.claude/settings.json'; + // The path is shell-quoted so it survives spaces/metacharacters: + // double-quoted on Windows, single-quoted on Unix. const expectedPretoolCommand = IS_WINDOWS - ? `powershell -NoProfile -ExecutionPolicy Bypass -File ${pretoolScriptRel}` - : pretoolScriptRel; + ? `powershell -NoProfile -ExecutionPolicy Bypass -File "${pretoolScriptRel}"` + : `'${pretoolScriptRel}'`; const expectedPromptCommand = IS_WINDOWS - ? `powershell -NoProfile -ExecutionPolicy Bypass -File ${promptScriptRel}` - : promptScriptRel; + ? `powershell -NoProfile -ExecutionPolicy Bypass -File "${promptScriptRel}"` + : `'${promptScriptRel}'`; harness.cwd.writeFile( pretoolScriptRel, diff --git a/tests/unit/cli/commands/integrate/_common/hooks.test.ts b/tests/unit/cli/commands/integrate/_common/hooks.test.ts index e8072900d..d00961324 100644 --- a/tests/unit/cli/commands/integrate/_common/hooks.test.ts +++ b/tests/unit/cli/commands/integrate/_common/hooks.test.ts @@ -18,7 +18,16 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -26,15 +35,20 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { assertSafeSonarProjectKeyForHookScript, + createAgentHookEntry, formatSqaaPostToolHookCommandUnix, + quoteWindowsHookScriptPath, readOrInitJson, + resolveAgentHookCommand, shellQuoteBash, UNIX_SONAR_COMMAND_GUARD, unixTemplate, + upsertAgentHooks, WINDOWS_SONAR_COMMAND_GUARD, windowsTemplate, writeHookScript, } from '../../../../../../src/cli/commands/integrate/_common/hooks'; +import type { IntegrationContext } from '../../../../../../src/cli/commands/integrate/_common/registry'; const IS_WINDOWS = process.platform === 'win32'; @@ -128,6 +142,175 @@ describe('shellQuoteBash', () => { }); }); +describe('quoteWindowsHookScriptPath', () => { + it('wraps the path in double quotes so spaces survive PowerShell -File parsing', () => { + expect(quoteWindowsHookScriptPath('C:/Users/Jane Doe/.claude/hooks/x.ps1')).toBe( + '"C:/Users/Jane Doe/.claude/hooks/x.ps1"', + ); + }); +}); + +describe('resolveAgentHookCommand', () => { + const fakeContext = (targetRoot: string, scope: 'global' | 'project'): IntegrationContext => + ({ + targetRoot, + scope, + attrs: {}, + state: {} as never, + executionMode: 'install', + resolvedDependencies: new Map(), + }) as unknown as IntegrationContext; + + const SCRIPT = 'sonar-secrets/build-scripts/pretool-secrets'; + + it.skipIf(IS_WINDOWS)( + 'single-quotes an absolute global path that contains a space (Unix)', + () => { + const command = resolveAgentHookCommand( + fakeContext('/Users/Jane Doe/proj', 'global'), + '.claude', + SCRIPT, + ); + expect(command).toBe( + "'/Users/Jane Doe/proj/.claude/hooks/sonar-secrets/build-scripts/pretool-secrets.sh'", + ); + }, + ); + + it.skipIf(IS_WINDOWS)( + 'escapes an embedded apostrophe in targetRoot so the path stays a single Bash argument (Unix)', + () => { + const command = resolveAgentHookCommand( + fakeContext("/Users/O'Brien/proj", 'global'), + '.claude', + SCRIPT, + ); + // Close quote, escaped literal apostrophe, reopen quote: 'O'\''Brien' + expect(command).toBe( + "'/Users/O'\\''Brien/proj/.claude/hooks/sonar-secrets/build-scripts/pretool-secrets.sh'", + ); + // Marker still present for idempotency matching. + expect(command).toContain('sonar-secrets'); + }, + ); + + it.skipIf(IS_WINDOWS)('single-quotes the relative project-scope path (Unix)', () => { + const command = resolveAgentHookCommand(fakeContext('/tmp/proj', 'project'), '.claude', SCRIPT); + expect(command).toBe("'.claude/hooks/sonar-secrets/build-scripts/pretool-secrets.sh'"); + }); + + it.skipIf(!IS_WINDOWS)( + 'double-quotes an absolute global path that contains a space (Windows)', + () => { + const command = resolveAgentHookCommand( + fakeContext('C:/Users/Jane Doe/proj', 'global'), + '.claude', + SCRIPT, + ); + expect(command).toContain('powershell -NoProfile -ExecutionPolicy Bypass -File "'); + expect(command).toContain('/Jane Doe/'); + expect(command.endsWith('pretool-secrets.ps1"')).toBe(true); + }, + ); + + it.skipIf(IS_WINDOWS)( + 'the generated command executes through /bin/sh when the path contains a space and an apostrophe', + () => { + const base = mkdtempSync(join(tmpdir(), 'sonar-hook-exec-')); + try { + // A global-scope absolute path is what carries the space into the command. + const targetRoot = join(base, "o'brien project dir"); + const scriptDir = join(targetRoot, '.claude', 'hooks', 'sonar-secrets', 'build-scripts'); + mkdirSync(scriptDir, { recursive: true }); + const scriptFile = join(scriptDir, 'pretool-secrets.sh'); + writeFileSync(scriptFile, '#!/bin/sh\necho HOOK_RAN\n'); + chmodSync(scriptFile, 0o755); + + const command = resolveAgentHookCommand( + fakeContext(targetRoot, 'global'), + '.claude', + SCRIPT, + ); + // Precondition: this is the bug scenario — the command really contains a space. + expect(command).toContain(' '); + + // The agent runs the stored command through a shell; the quoting must let it execute. + expect(execFileSync('/bin/sh', ['-c', command], { encoding: 'utf-8' }).trim()).toBe( + 'HOOK_RAN', + ); + + // Negative control: the same path unquoted is split / mis-parsed by the shell and fails. + // (stdio: 'ignore' keeps the expected shell error off the test log.) + expect(() => execFileSync('/bin/sh', ['-c', scriptFile], { stdio: 'ignore' })).toThrow(); + } finally { + rmSync(base, { recursive: true, force: true }); + } + }, + ); +}); + +describe('upsertAgentHooks idempotency', () => { + const fakeContext = { + targetRoot: '/Users/Jane Doe/proj', + scope: 'global', + attrs: {}, + state: {} as never, + executionMode: 'install', + resolvedDependencies: new Map(), + } as unknown as IntegrationContext; + + it('does not duplicate the entry when re-run with a quoted path', () => { + const entry = createAgentHookEntry( + fakeContext, + '.claude', + 'PreToolUse', + 'Read', + 'sonar-secrets', + 'sonar-secrets/build-scripts/pretool-secrets', + ); + + const first = upsertAgentHooks(null, [entry]); + const second = upsertAgentHooks(first, [entry]); + + expect(second.hooks?.PreToolUse).toHaveLength(1); + }); + + it('replaces a previously-unquoted entry (upgrade path) instead of shadowing it', () => { + // Simulate a settings.json written by an older CLI: bare, unquoted path. + const legacy = { + hooks: { + PreToolUse: [ + { + matcher: 'Read', + hooks: [ + { + type: 'command', + command: + '/Users/Jane Doe/proj/.claude/hooks/sonar-secrets/build-scripts/pretool-secrets.sh', + timeout: 60, + }, + ], + }, + ], + }, + }; + const entry = createAgentHookEntry( + fakeContext, + '.claude', + 'PreToolUse', + 'Read', + 'sonar-secrets', + 'sonar-secrets/build-scripts/pretool-secrets', + ); + + const result = upsertAgentHooks(legacy, [entry]); + + expect(result.hooks?.PreToolUse).toHaveLength(1); + // The retained entry is the freshly-quoted one. + expect(result.hooks?.PreToolUse?.[0].hooks[0].command).toBe(entry.hookConfig.hooks[0].command); + }); +}); + describe('readOrInitJson', () => { let workDir: string; diff --git a/tests/unit/cli/commands/integrate/claude/hooks.test.ts b/tests/unit/cli/commands/integrate/claude/hooks.test.ts index 7d5301989..8e899a97b 100644 --- a/tests/unit/cli/commands/integrate/claude/hooks.test.ts +++ b/tests/unit/cli/commands/integrate/claude/hooks.test.ts @@ -203,12 +203,12 @@ describe('installHooks', () => { // so platform-specific assertions branch on the real // host platform. const IS_WINDOWS = process.platform === 'win32'; - // On Windows the registered command is `powershell -NoProfile -ExecutionPolicy Bypass -File .claude/...` - // (forward slashes via replaceAll); on Unix it is the bare `.claude/...` path. + // On Windows the registered command is `powershell -NoProfile -ExecutionPolicy Bypass -File ".claude/..."` + // (forward slashes via replaceAll, path double-quoted); on Unix it is the single-quoted `'.claude/...'` path. const isProjectScopedCommand = (command: string): boolean => IS_WINDOWS - ? command.includes('powershell -NoProfile -ExecutionPolicy Bypass -File .claude/') - : command.startsWith('.claude/'); + ? command.includes('powershell -NoProfile -ExecutionPolicy Bypass -File ".claude/') + : command.startsWith("'.claude/"); let existsSyncSpy: Mock any>>; let mkdirSyncSpy: Mock any>>; @@ -315,6 +315,39 @@ describe('installHooks', () => { expect(normPath(String(command))).toContain(GLOBAL_DIR); }); + it('quotes a global command path containing a space so the shell keeps it one argument', async () => { + await installHooks(PROJECT_ROOT, '/fake/global dir'); + + const command = String( + getSettingsWriteFor('PreToolUse')?.hooks?.PreToolUse?.[0]?.hooks?.[0]?.command, + ); + if (IS_WINDOWS) { + expect(command).toContain('powershell -NoProfile -ExecutionPolicy Bypass -File "'); + expect(normPath(command)).toContain('/fake/global dir/'); + expect(command.endsWith('.ps1"')).toBe(true); + } else { + expect(command).toBe( + "'/fake/global dir/.claude/hooks/sonar-secrets/build-scripts/pretool-secrets.sh'", + ); + } + // Marker preserved so re-running still finds and replaces the entry. + expect(command).toContain('sonar-secrets'); + }); + + it.skipIf(IS_WINDOWS)( + 'escapes an embedded apostrophe in a global command path (Unix)', + async () => { + await installHooks(PROJECT_ROOT, "/fake/o'brien"); + + const command = String( + getSettingsWriteFor('PreToolUse')?.hooks?.PreToolUse?.[0]?.hooks?.[0]?.command, + ); + expect(command).toBe( + "'/fake/o'\\''brien/.claude/hooks/sonar-secrets/build-scripts/pretool-secrets.sh'", + ); + }, + ); + it('uses a relative command path for the SQAA hook regardless of globalDir', async () => { await installHooks(PROJECT_ROOT, GLOBAL_DIR, true, PROJECT_KEY); diff --git a/tests/unit/cli/commands/integrate/cursor/hooks.test.ts b/tests/unit/cli/commands/integrate/cursor/hooks.test.ts index 108c979ae..5c5c32e20 100644 --- a/tests/unit/cli/commands/integrate/cursor/hooks.test.ts +++ b/tests/unit/cli/commands/integrate/cursor/hooks.test.ts @@ -307,7 +307,8 @@ describe('buildCursorHookEntry', () => { /^powershell -NoProfile -ExecutionPolicy Bypass -File /, '', ); - expect(commandPath).toMatch(/^[/\\]/); + // The path is quoted (single quotes on Unix, double quotes on Windows); allow a leading quote. + expect(commandPath).toMatch(/^['"]?[/\\]/); }); it('sets failClosed to false by default', () => {