fix(security): add command validation to local provider's runLocal/interactiveSession (#3160)

The local provider was missing the empty-string and null-byte command
validation that all other cloud providers (AWS, GCP, Hetzner, DO, Sprite)
already enforce. While callers currently pass hardcoded commands, this adds
defense-in-depth parity with the rest of the codebase.

Fixes #3155

Agent: security-auditor

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
A 2026-04-02 22:18:04 -07:00 committed by GitHub
parent 15df9dfae3
commit 0ffa035e35
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 74 additions and 14 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.30.9",
"version": "0.30.10",
"type": "module",
"bin": {
"spawn": "cli.js"

View file

@ -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", () => {

View file

@ -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<void> {
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<number> {
validateCommand(cmd);
const [shell, flag] = getLocalShell();
return spawnInteractive([
shell,
@ -349,19 +358,18 @@ export async function pullAndStartContainer(agentName: string): Promise<void> {
}
/** Launch an interactive session inside the Docker container. */
export function dockerInteractiveSession(cmd: string): Promise<number> {
return Promise.resolve(
spawnInteractive([
"docker",
"exec",
"-it",
DOCKER_CONTAINER_NAME,
"bash",
"-l",
"-c",
cmd,
]),
);
export async function dockerInteractiveSession(cmd: string): Promise<number> {
validateCommand(cmd);
return spawnInteractive([
"docker",
"exec",
"-it",
DOCKER_CONTAINER_NAME,
"bash",
"-l",
"-c",
cmd,
]);
}
/** Remove the sandbox container (best-effort, for cleanup). */