mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-19 16:39:50 +00:00
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:
parent
15df9dfae3
commit
0ffa035e35
3 changed files with 74 additions and 14 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openrouter/spawn",
|
||||
"version": "0.30.9",
|
||||
"version": "0.30.10",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"spawn": "cli.js"
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue