diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 35222fd5..8863b505 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -21,6 +21,7 @@ import { runAppServerTurn } from "./lib/codex.mjs"; import { readStdinIfPiped } from "./lib/fs.mjs"; +import { readTaskPrompt } from "./lib/task-prompt.mjs"; import { collectReviewContext, ensureGitRepository, resolveReviewTarget } from "./lib/git.mjs"; import { binaryAvailable, terminateProcessTree } from "./lib/process.mjs"; import { loadPromptTemplate, interpolateTemplate } from "./lib/prompts.mjs"; @@ -610,14 +611,6 @@ function buildTaskRequest({ cwd, model, effort, prompt, write, resumeLast, jobId }; } -function readTaskPrompt(cwd, options, positionals) { - if (options["prompt-file"]) { - return fs.readFileSync(path.resolve(cwd, options["prompt-file"]), "utf8"); - } - - const positionalPrompt = positionals.join(" "); - return positionalPrompt || readStdinIfPiped(); -} function requireTaskRequest(prompt, resumeLast) { if (!prompt && !resumeLast) { diff --git a/plugins/codex/scripts/lib/task-prompt.mjs b/plugins/codex/scripts/lib/task-prompt.mjs new file mode 100644 index 00000000..b3f2763f --- /dev/null +++ b/plugins/codex/scripts/lib/task-prompt.mjs @@ -0,0 +1,42 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { readStdinIfPiped } from "./fs.mjs"; + +export function readTaskPrompt(cwd, options, positionals) { + if (options["prompt-file"]) { + return readPromptFile(cwd, options["prompt-file"]); + } + const positionalPrompt = positionals.join(" "); + return positionalPrompt || readStdinIfPiped(); +} + +function readPromptFile(cwd, promptFileOption) { + const realCwd = fs.realpathSync(cwd); + const resolved = path.resolve(realCwd, promptFileOption); + + // Resolve symlinks. If the file does not yet exist, fall back to resolving + // the parent directory and recombining — this preserves the existing + // behavior where readFileSync would throw a clear ENOENT. + let realResolved; + try { + realResolved = fs.realpathSync(resolved); + } catch (error) { + if (error && error.code === "ENOENT") { + const parent = path.dirname(resolved); + const realParent = fs.realpathSync(parent); + realResolved = path.join(realParent, path.basename(resolved)); + } else { + throw error; + } + } + + const rel = path.relative(realCwd, realResolved); + if (rel !== "" && (rel.startsWith("..") || path.isAbsolute(rel))) { + throw new Error( + `--prompt-file must be a path inside ${realCwd} (got ${realResolved})` + ); + } + + return fs.readFileSync(realResolved, "utf8"); +} diff --git a/tests/task-prompt.test.mjs b/tests/task-prompt.test.mjs new file mode 100644 index 00000000..5767b2f0 --- /dev/null +++ b/tests/task-prompt.test.mjs @@ -0,0 +1,71 @@ +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; + +import { readTaskPrompt } from "../plugins/codex/scripts/lib/task-prompt.mjs"; +import { makeTempDir } from "./helpers.mjs"; + +test("happy path — relative prompt-file inside cwd is read and returned", () => { + const cwd = makeTempDir(); + fs.writeFileSync(path.join(cwd, "prompt.txt"), "hello from file"); + const result = readTaskPrompt(cwd, { "prompt-file": "prompt.txt" }, []); + assert.equal(result, "hello from file"); +}); + +test("happy path — absolute path inside cwd is accepted", () => { + const cwd = makeTempDir(); + const filePath = path.join(cwd, "prompt.txt"); + fs.writeFileSync(filePath, "absolute path content"); + const result = readTaskPrompt(cwd, { "prompt-file": filePath }, []); + assert.equal(result, "absolute path content"); +}); + +test("rejection — relative path escaping cwd throws", () => { + const inside = makeTempDir(); + const outside = makeTempDir(); + fs.writeFileSync(path.join(outside, "secret.txt"), "secret"); + const outsideBase = path.basename(outside); + assert.throws( + () => readTaskPrompt(inside, { "prompt-file": `../${outsideBase}/secret.txt` }, []), + /must be (a path )?inside/i + ); +}); + +test("rejection — absolute path outside cwd throws", () => { + const inside = makeTempDir(); + const outside = makeTempDir(); + const secretPath = path.join(outside, "secret.txt"); + fs.writeFileSync(secretPath, "secret"); + assert.throws( + () => readTaskPrompt(inside, { "prompt-file": secretPath }, []), + /must be (a path )?inside/i + ); +}); + +test("rejection — symlink inside cwd pointing outside cwd throws", { + skip: process.platform === "win32" +}, () => { + const inside = makeTempDir(); + const outside = makeTempDir(); + fs.writeFileSync(path.join(outside, "secret.txt"), "secret"); + fs.symlinkSync(path.join(outside, "secret.txt"), path.join(inside, "link.txt")); + assert.throws( + () => readTaskPrompt(inside, { "prompt-file": "link.txt" }, []), + /must be (a path )?inside/i + ); +}); + +test("fallback — no prompt-file uses positional args joined with space", () => { + const cwd = makeTempDir(); + const result = readTaskPrompt(cwd, {}, ["hello", "world"]); + assert.equal(result, "hello world"); +}); + +test("fallback — no prompt-file and no positionals returns empty string when stdin is a TTY", { + skip: !process.stdin.isTTY +}, () => { + const cwd = makeTempDir(); + const result = readTaskPrompt(cwd, {}, []); + assert.equal(result, ""); +});