diff --git a/packages/cli/package.json b/packages/cli/package.json index dd5d354d..7bdf66e5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.30.9", + "version": "0.30.10", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/__tests__/sandbox.test.ts b/packages/cli/src/__tests__/sandbox.test.ts index e182ab22..603bc27b 100644 --- a/packages/cli/src/__tests__/sandbox.test.ts +++ b/packages/cli/src/__tests__/sandbox.test.ts @@ -5,9 +5,12 @@ mockClackPrompts(); import { cleanupContainer, + dockerInteractiveSession, ensureDocker, + interactiveSession, isDockerAvailable, pullAndStartContainer, + runLocal, runLocalArgs, validateAgentName, validateLocalPath, @@ -228,6 +231,55 @@ describe("runLocalArgs", () => { }); }); +// ─── runLocal command validation ──────────────────────────────────────────── + +describe("runLocal", () => { + it("rejects empty command", async () => { + await expect(runLocal("")).rejects.toThrow("Invalid command"); + }); + + it("rejects null byte in command", async () => { + await expect(runLocal("echo\x00hello")).rejects.toThrow("Invalid command"); + }); + + it("runs shell command and resolves on success", async () => { + const spawnSpy = mockBunSpawn(0); + await runLocal("echo hello"); + expect(spawnSpy).toHaveBeenCalled(); + spawnSpy.mockRestore(); + }); + + it("throws on non-zero exit code", async () => { + const spawnSpy = mockBunSpawn(1); + await expect(runLocal("failing-cmd")).rejects.toThrow("Command failed"); + spawnSpy.mockRestore(); + }); +}); + +// ─── interactiveSession command validation ────────────────────────────────── + +describe("local/interactiveSession", () => { + it("rejects empty command", async () => { + await expect(interactiveSession("")).rejects.toThrow("Invalid command"); + }); + + it("rejects null byte in command", async () => { + await expect(interactiveSession("echo\x00hi")).rejects.toThrow("Invalid command"); + }); +}); + +// ─── dockerInteractiveSession command validation ──────────────────────────── + +describe("dockerInteractiveSession", () => { + it("rejects empty command", async () => { + await expect(dockerInteractiveSession("")).rejects.toThrow("Invalid command"); + }); + + it("rejects null byte in command", async () => { + await expect(dockerInteractiveSession("echo\x00hi")).rejects.toThrow("Invalid command"); + }); +}); + // ─── cleanupContainer ─────────────────────────────────────────────────────── describe("cleanupContainer", () => { diff --git a/packages/cli/src/local/local.ts b/packages/cli/src/local/local.ts index 625007ef..c39d3d10 100644 --- a/packages/cli/src/local/local.ts +++ b/packages/cli/src/local/local.ts @@ -49,8 +49,16 @@ export function validateLocalPath(filePath: string): string { // ─── Execution ─────────────────────────────────────────────────────────────── +/** Validate a command string: must be non-empty and free of null bytes. */ +function validateCommand(cmd: string): void { + if (!cmd || cmd.includes("\0")) { + throw new Error("Invalid command: must be non-empty and must not contain null bytes"); + } +} + /** Run a shell command locally and wait for it to finish. */ export async function runLocal(cmd: string): Promise { + validateCommand(cmd); const [shell, flag] = getLocalShell(); const proc = Bun.spawn( [ @@ -113,6 +121,7 @@ export function downloadFile(remotePath: string, localPath: string): void { /** Launch an interactive shell session locally. */ export async function interactiveSession(cmd: string): Promise { + validateCommand(cmd); const [shell, flag] = getLocalShell(); return spawnInteractive([ shell, @@ -349,19 +358,18 @@ export async function pullAndStartContainer(agentName: string): Promise { } /** Launch an interactive session inside the Docker container. */ -export function dockerInteractiveSession(cmd: string): Promise { - return Promise.resolve( - spawnInteractive([ - "docker", - "exec", - "-it", - DOCKER_CONTAINER_NAME, - "bash", - "-l", - "-c", - cmd, - ]), - ); +export async function dockerInteractiveSession(cmd: string): Promise { + validateCommand(cmd); + return spawnInteractive([ + "docker", + "exec", + "-it", + DOCKER_CONTAINER_NAME, + "bash", + "-l", + "-c", + cmd, + ]); } /** Remove the sandbox container (best-effort, for cleanup). */