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.30.5",
"version": "0.30.6",
"type": "module",
"bin": {
"spawn": "cli.js"
Expand Down
41 changes: 40 additions & 1 deletion packages/cli/src/__tests__/prompt-file-security.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { validatePromptFilePath, validatePromptFileStats } from "../security.js";
import { stripControlChars, validatePromptFilePath, validatePromptFileStats } from "../security.js";

describe("validatePromptFilePath", () => {
it("should accept normal text file paths", () => {
Expand Down Expand Up @@ -158,6 +158,45 @@ describe("validatePromptFilePath", () => {
expect(() => validatePromptFilePath(symlink)).not.toThrow();
});
});

it("should reject paths containing ANSI escape sequences", () => {
expect(() => validatePromptFilePath("\x1b[2J\x1b[Hfake.txt")).toThrow("control characters");
expect(() => validatePromptFilePath("file\x1b[31mred.txt")).toThrow("control characters");
});

it("should reject paths containing null bytes", () => {
expect(() => validatePromptFilePath("file\x00.txt")).toThrow("control characters");
});

it("should reject paths containing other control characters", () => {
expect(() => validatePromptFilePath("file\x07bell.txt")).toThrow("control characters");
expect(() => validatePromptFilePath("file\x08backspace.txt")).toThrow("control characters");
expect(() => validatePromptFilePath("file\x7Fdel.txt")).toThrow("control characters");
});
});

describe("stripControlChars", () => {
it("should strip ANSI escape sequences", () => {
expect(stripControlChars("\x1b[2J\x1b[Hfake.txt")).toBe("[2J[Hfake.txt");
});

it("should strip null bytes", () => {
expect(stripControlChars("file\x00.txt")).toBe("file.txt");
});

it("should strip bell, backspace, and DEL", () => {
expect(stripControlChars("file\x07\x08\x7F.txt")).toBe("file.txt");
});

it("should preserve tabs and newlines", () => {
expect(stripControlChars("line1\nline2\ttab")).toBe("line1\nline2\ttab");
});

it("should return normal strings unchanged", () => {
expect(stripControlChars("/tmp/prompt.txt")).toBe("/tmp/prompt.txt");
expect(stripControlChars("")).toBe("");
expect(stripControlChars("hello world")).toBe("hello world");
});
});

describe("validatePromptFileStats", () => {
Expand Down
15 changes: 10 additions & 5 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,19 +317,24 @@ async function suggestCloudsForPrompt(agent: string): Promise<void> {

/** Print a descriptive error for a failed prompt file read and exit */
function handlePromptFileError(promptFile: string, err: unknown): never {
// SECURITY: Strip control characters to prevent terminal injection via crafted paths.
// validatePromptFilePath() rejects these early, but this is defense-in-depth for
// error paths that run before validation (e.g., stat failures).
// Inline the same regex from security.ts to avoid async import in a sync function.
const safePath = promptFile.replace(/[\x00-\x08\x0B-\x1F\x7F]/g, "");
const errObj = toRecord(err);
const code = isString(errObj?.code) ? errObj.code : "";
if (code === "ENOENT") {
console.error(pc.red(`Prompt file not found: ${pc.bold(promptFile)}`));
console.error(pc.red(`Prompt file not found: ${pc.bold(safePath)}`));
console.error("\nCheck the path and try again.");
} else if (code === "EACCES") {
console.error(pc.red(`Permission denied reading prompt file: ${pc.bold(promptFile)}`));
console.error(`\nCheck file permissions: ${pc.cyan(`ls -la ${promptFile}`)}`);
console.error(pc.red(`Permission denied reading prompt file: ${pc.bold(safePath)}`));
console.error(`\nCheck file permissions: ${pc.cyan(`ls -la ${safePath}`)}`);
} else if (code === "EISDIR") {
console.error(pc.red(`'${promptFile}' is a directory, not a file.`));
console.error(pc.red(`'${safePath}' is a directory, not a file.`));
console.error("\nProvide a path to a text file containing your prompt.");
} else {
console.error(pc.red(`Error reading prompt file '${promptFile}': ${getErrorMessage(err)}`));
console.error(pc.red(`Error reading prompt file '${safePath}': ${getErrorMessage(err)}`));
}
process.exit(1);
}
Expand Down
22 changes: 22 additions & 0 deletions packages/cli/src/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,18 @@ export function validateTunnelPort(port: string): void {
}
}

/**
* Strip ASCII control characters from a string for safe terminal display.
* Removes characters 0x00-0x1F and 0x7F, preserving tab (0x09) and newline (0x0A).
* SECURITY-CRITICAL: Prevents ANSI escape sequence injection in error messages.
*
* @param s - The string to sanitize
* @returns The string with control characters removed
*/
export function stripControlChars(s: string): string {
return s.replace(/[\x00-\x08\x0B-\x1F\x7F]/g, "");
}

// Sensitive path patterns that should never be read as prompt files
// These protect credentials and system files from accidental exfiltration
const SENSITIVE_PATH_PATTERNS: ReadonlyArray<{
Expand Down Expand Up @@ -632,6 +644,16 @@ export function validatePromptFilePath(filePath: string): void {
);
}

// Reject paths containing control characters (ANSI escape sequences, null bytes, etc.)
// These can cause terminal injection when displayed in error messages.
if (/[\x00-\x08\x0B-\x1F\x7F]/.test(filePath)) {
throw new Error(
"Prompt file path contains control characters (e.g., ANSI escape sequences).\n\n" +
"File paths must be plain text without terminal control codes.\n" +
"Check that the path was entered correctly.",
);
}

// Normalize the path to resolve .. and textual tricks
let resolved = resolve(filePath);

Expand Down
Loading