Skip to content

Commit 420525e

Browse files
betegonclaude
andcommitted
fix(init): run commands without shell to eliminate injection surface
Replace spawn({ shell: true }) with direct executable invocation by splitting the command string into [executable, ...args]. This eliminates shell injection as an attack vector entirely — metacharacters become harmless literal arguments. The existing validateCommand() blocklist is retained as defense-in-depth. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4033b4e commit 420525e

File tree

2 files changed

+12
-9
lines changed

2 files changed

+12
-9
lines changed

src/lib/init/local-ops.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -532,8 +532,9 @@ async function runCommands(
532532
return { ok: true, data: { results } };
533533
}
534534

535-
// Note: shell: true targets Unix shells. Windows cmd.exe metacharacters
536-
// (%, ^) are not blocked; the CLI assumes a Unix Node.js environment.
535+
// Runs the executable directly (no shell) to eliminate shell injection as an
536+
// attack vector. The command string is split on whitespace into [exe, ...args].
537+
// validateCommand() still blocks metacharacters as defense-in-depth.
537538
function runSingleCommand(
538539
command: string,
539540
cwd: string,
@@ -545,8 +546,8 @@ function runSingleCommand(
545546
stderr: string;
546547
}> {
547548
return new Promise((resolve) => {
548-
const child = spawn(command, [], {
549-
shell: true,
549+
const [executable = "", ...args] = command.trim().split(WHITESPACE_RE);
550+
const child = spawn(executable, args, {
550551
cwd,
551552
stdio: ["ignore", "pipe", "pipe"],
552553
timeout: timeoutMs,

test/lib/init/local-ops.test.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -616,7 +616,7 @@ describe("handleLocalOp", () => {
616616
type: "local-op",
617617
operation: "run-commands",
618618
cwd: testDir,
619-
params: { commands: ["echo hello"] },
619+
params: { commands: ["/bin/echo hello"] },
620620
};
621621

622622
const result = await handleLocalOp(payload, options);
@@ -664,7 +664,9 @@ describe("handleLocalOp", () => {
664664
type: "local-op",
665665
operation: "run-commands",
666666
cwd: testDir,
667-
params: { commands: ["false", "echo should_not_run"] },
667+
params: {
668+
commands: ["/usr/bin/false", "/bin/echo should_not_run"],
669+
},
668670
};
669671

670672
const result = await handleLocalOp(payload, options);
@@ -675,7 +677,7 @@ describe("handleLocalOp", () => {
675677
}
676678
).results;
677679
expect(results).toHaveLength(1);
678-
expect(results[0].command).toBe("false");
680+
expect(results[0].command).toBe("/usr/bin/false");
679681
});
680682

681683
test("dry-run validates commands but skips execution", async () => {
@@ -697,7 +699,7 @@ describe("handleLocalOp", () => {
697699
type: "local-op",
698700
operation: "run-commands",
699701
cwd: testDir,
700-
params: { commands: ["npm install @sentry/node", "echo hello"] },
702+
params: { commands: ["npm install @sentry/node", "/bin/echo hello"] },
701703
};
702704

703705
const dryRunOptions = makeOptions({ dryRun: true, directory: testDir });
@@ -718,7 +720,7 @@ describe("handleLocalOp", () => {
718720
type: "local-op",
719721
operation: "run-commands",
720722
cwd: testDir,
721-
params: { commands: ["echo hello", "rm -rf /"] },
723+
params: { commands: ["/bin/echo hello", "rm -rf /"] },
722724
};
723725

724726
const result = await handleLocalOp(payload, options);

0 commit comments

Comments
 (0)