Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions src/cli/commands/integrate/_common/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}"`;
}
Comment thread
gitar-bot[bot] marked this conversation as resolved.

export type SqaaHookSubcommand = 'claude-post-tool-use' | 'codex-post-tool-use';

export function formatSqaaHookCliArgsUnix(
Expand Down Expand Up @@ -172,9 +188,12 @@ export function resolveAgentHookScriptPath(
}

/**
* Hook `command` string: `powershell -NoProfile -ExecutionPolicy Bypass -File <path>` 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 "<path>"` 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,
Expand All @@ -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(
Expand Down
14 changes: 11 additions & 3 deletions src/cli/commands/integrate/claude/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -115,9 +121,11 @@ async function installHook(params: HookInstallParams): Promise<void> {
// 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];
Expand Down
22 changes: 18 additions & 4 deletions tests/integration/harness/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>`;
* this strips that prefix. Always normalizes to forward slashes.
* On Windows commands are wrapped as `powershell -NoProfile -ExecutionPolicy Bypass -File "<path>"`;
* 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;
}
80 changes: 60 additions & 20 deletions tests/integration/specs/integrate/claude.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 },
);
Expand Down Expand Up @@ -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 },
);
Expand Down
34 changes: 33 additions & 1 deletion tests/integration/specs/integrate/codex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down Expand Up @@ -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 () => {
Expand Down
10 changes: 6 additions & 4 deletions tests/integration/specs/self-update/post-update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading