Skip to content
Merged
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
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.16.16",
"version": "0.16.17",
"type": "module",
"bin": {
"spawn": "cli.js"
Expand Down
71 changes: 71 additions & 0 deletions packages/cli/src/__tests__/gcp-shellquote.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, expect, it } from "bun:test";
import { shellQuote } from "../gcp/gcp.js";

describe("shellQuote", () => {
it("should wrap simple strings in single quotes", () => {
expect(shellQuote("hello")).toBe("'hello'");
expect(shellQuote("ls -la")).toBe("'ls -la'");
});

it("should escape embedded single quotes", () => {
expect(shellQuote("it's")).toBe("'it'\\''s'");
expect(shellQuote("a'b'c")).toBe("'a'\\''b'\\''c'");
});

it("should handle strings with no special characters", () => {
expect(shellQuote("simple")).toBe("'simple'");
expect(shellQuote("/usr/bin/env")).toBe("'/usr/bin/env'");
});

it("should safely quote shell metacharacters", () => {
expect(shellQuote("$(whoami)")).toBe("'$(whoami)'");
expect(shellQuote("`id`")).toBe("'`id`'");
expect(shellQuote("a; rm -rf /")).toBe("'a; rm -rf /'");
expect(shellQuote("a | cat /etc/passwd")).toBe("'a | cat /etc/passwd'");
expect(shellQuote("a && curl evil.com")).toBe("'a && curl evil.com'");
expect(shellQuote("${HOME}")).toBe("'${HOME}'");
});

it("should handle double quotes inside single-quoted string", () => {
expect(shellQuote('echo "hello"')).toBe("'echo \"hello\"'");
});

it("should handle empty string", () => {
expect(shellQuote("")).toBe("''");
});

it("should reject null bytes (defense-in-depth)", () => {
expect(() => shellQuote("hello\x00world")).toThrow("null bytes");
expect(() => shellQuote("\x00")).toThrow("null bytes");
expect(() => shellQuote("cmd\x00; rm -rf /")).toThrow("null bytes");
});

it("should handle strings with newlines", () => {
const result = shellQuote("line1\nline2");
expect(result).toBe("'line1\nline2'");
});

it("should handle strings with tabs", () => {
const result = shellQuote("col1\tcol2");
expect(result).toBe("'col1\tcol2'");
});

it("should handle backslashes", () => {
expect(shellQuote("a\\b")).toBe("'a\\b'");
});

it("should handle multiple consecutive single quotes", () => {
expect(shellQuote("''")).toBe("''\\'''\\'''");
});

it("should produce output that is safe for bash -c", () => {
// Verify the quoting pattern: the result, when interpreted by bash,
// should yield the original string without executing anything
const dangerous = "$(rm -rf /)";
const quoted = shellQuote(dangerous);
// The quoted string wraps in single quotes, preventing expansion
expect(quoted).toBe("'$(rm -rf /)'");
expect(quoted.startsWith("'")).toBe(true);
expect(quoted.endsWith("'")).toBe(true);
});
});
15 changes: 11 additions & 4 deletions packages/cli/src/gcp/gcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1022,9 +1022,8 @@ export async function interactiveSession(cmd: string): Promise<number> {
}
const username = resolveUsername();
const term = sanitizeTermValue(process.env.TERM || "xterm-256color");
// Single-quote escaping prevents premature shell expansion of $variables in cmd
const shellEscapedCmd = cmd.replace(/'/g, "'\\''");
const fullCmd = `export TERM=${term} PATH="$HOME/.npm-global/bin:$HOME/.claude/local/bin:$HOME/.local/bin:$HOME/.bun/bin:$PATH" && exec bash -l -c '${shellEscapedCmd}'`;
// Use shellQuote for consistent single-quote escaping (prevents shell expansion of $variables in cmd)
const fullCmd = `export TERM=${term} PATH="$HOME/.npm-global/bin:$HOME/.claude/local/bin:$HOME/.local/bin:$HOME/.bun/bin:$PATH" && exec bash -l -c ${shellQuote(cmd)}`;
const keyOpts = getSshKeyOpts(await ensureSshKeys());

const exitCode = spawnInteractive([
Expand Down Expand Up @@ -1084,6 +1083,14 @@ export async function destroyInstance(name?: string): Promise<void> {

// ─── Shell Quoting ──────────────────────────────────────────────────────────

function shellQuote(s: string): string {
/** POSIX single-quote escaping: wraps `s` in single quotes and escapes any
* embedded single quotes with the standard `'\''` technique.
*
* Defense-in-depth: rejects null bytes which could truncate the string at
* the C/OS level even though callers already validate for them. */
export function shellQuote(s: string): string {
if (/\0/.test(s)) {
throw new Error("shellQuote: input must not contain null bytes");
}
return "'" + s.replace(/'/g, "'\\''") + "'";
}
Loading