diff --git a/backlog/tasks/task-270 - Prevent-command-substitution-in-task-creation-inputs.md b/backlog/tasks/task-270 - Prevent-command-substitution-in-task-creation-inputs.md index 3d428e86c..51ba864f5 100644 --- a/backlog/tasks/task-270 - Prevent-command-substitution-in-task-creation-inputs.md +++ b/backlog/tasks/task-270 - Prevent-command-substitution-in-task-creation-inputs.md @@ -1,10 +1,11 @@ --- id: task-270 title: Prevent command substitution in task creation inputs -status: To Do +status: Done assignee: - '@codex' created_date: '2025-09-17 21:20' +updated_date: '2025-09-18 21:01' labels: [] dependencies: [] --- diff --git a/src/cli.ts b/src/cli.ts index 67bbea995..4cd40cada 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -23,6 +23,7 @@ import { createLoadingScreen } from "./ui/loading.ts"; import { formatTaskPlainText, viewTaskEnhanced } from "./ui/task-viewer.ts"; import { promptText, scrollableViewer } from "./ui/tui.ts"; import { type AgentSelectionValue, PLACEHOLDER_AGENT_VALUE, processAgentSelection } from "./utils/agent-selection.ts"; +import { sanitizeBackticks, sanitizeOptions } from "./utils/sanitize-backticks.ts"; import { formatValidStatuses, getCanonicalStatus, getValidStatuses } from "./utils/status.ts"; import { getTaskFilename, getTaskPath } from "./utils/task-path.ts"; import { sortTasks } from "./utils/task-sorting.ts"; @@ -1038,7 +1039,12 @@ taskCmd const core = new Core(cwd); await core.ensureConfigLoaded(); const id = await core.generateNextId(options.parent); - const task = buildTaskFromOptions(id, title, options); + + // Sanitize all options to prevent command substitution + const sanitizedOptions = sanitizeOptions(options); + const sanitizedTitle = sanitizeBackticks(title) || title; + + const task = buildTaskFromOptions(id, sanitizedTitle, sanitizedOptions); // Normalize and validate status if provided (case-insensitive) if (options.status) { @@ -1334,6 +1340,10 @@ taskCmd .action(async (taskId: string, options) => { const cwd = process.cwd(); const core = new Core(cwd); + + // Sanitize all options to prevent command substitution + const sanitizedOptions = sanitizeOptions(options); + const task = await core.filesystem.loadTask(taskId); if (!task) { @@ -1341,11 +1351,11 @@ taskCmd return; } - if (options.title) { - task.title = String(options.title); + if (sanitizedOptions.title) { + task.title = String(sanitizedOptions.title); } - if (options.description || options.desc) { - task.description = String(options.description || options.desc); + if (sanitizedOptions.description || sanitizedOptions.desc) { + task.description = String(sanitizedOptions.description || sanitizedOptions.desc); } if (typeof options.assignee !== "undefined") { task.assignee = [String(options.assignee)]; @@ -1425,7 +1435,7 @@ taskCmd const { AcceptanceCriteriaManager } = await import("./markdown/structured-sections.ts"); // Handle adding new acceptance criteria (unified handling for both --ac and --acceptance-criteria) - const criteria = processAcceptanceCriteriaOptions(options); + const criteria = processAcceptanceCriteriaOptions(sanitizedOptions); if (criteria.length > 0) { // Merge new criteria into structured list (fallback to parsing body for legacy) const current = @@ -1479,24 +1489,26 @@ taskCmd } // Handle implementation plan - if (options.plan) { - task.implementationPlan = String(options.plan); + if (sanitizedOptions.plan) { + task.implementationPlan = String(sanitizedOptions.plan); } // Handle implementation notes - replace or append - if (options.appendNotes && options.notes) { + if (sanitizedOptions.appendNotes && sanitizedOptions.notes) { console.error("Cannot use --notes (replace) together with --append-notes (append). Choose one."); process.exitCode = 1; return; } - if (options.notes) { + if (sanitizedOptions.notes) { // Replace semantics - task.implementationNotes = String(options.notes); + task.implementationNotes = String(sanitizedOptions.notes); } - if (options.appendNotes) { - const appends = Array.isArray(options.appendNotes) ? options.appendNotes : [options.appendNotes]; + if (sanitizedOptions.appendNotes) { + const appends = Array.isArray(sanitizedOptions.appendNotes) + ? sanitizedOptions.appendNotes + : [sanitizedOptions.appendNotes]; const combined = appends .map((v: string) => String(v)) .filter(Boolean) diff --git a/src/test/sanitize-backticks.test.ts b/src/test/sanitize-backticks.test.ts new file mode 100644 index 000000000..ebb4c9576 --- /dev/null +++ b/src/test/sanitize-backticks.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; +import { sanitizeBackticks, sanitizeOptions } from "../utils/sanitize-backticks.ts"; + +describe("sanitizeBackticks", () => { + test("should detect and replace backlog command output", () => { + const input = "backlog init\nSelect agent type:\n1. Claude\n2. GitHub Copilot"; + const result = sanitizeBackticks(input); + expect(result).toBe( + "[backticks were here but command was executed - please use single quotes to include literal backticks]", + ); + }); + + test("should detect prompt patterns", () => { + const input = "Select your option:"; + const result = sanitizeBackticks(input); + expect(result).toBe("[command substitution detected - please escape backticks]"); + }); + + test("should leave normal text unchanged", () => { + const input = "This is a normal task description"; + const result = sanitizeBackticks(input); + expect(result).toBe(input); + }); + + test("should handle undefined input", () => { + expect(sanitizeBackticks(undefined)).toBe(undefined); + }); +}); + +describe("sanitizeOptions", () => { + test("should sanitize description field", () => { + const options = { + description: "backlog task list output here", + otherField: "unchanged", + }; + const result = sanitizeOptions(options); + expect(result.description).toBe( + "[backticks were here but command was executed - please use single quotes to include literal backticks]", + ); + expect(result.otherField).toBe("unchanged"); + }); + + test("should sanitize array fields", () => { + const options = { + ac: ["normal criteria", "Select type:", "another normal one"], + }; + const result = sanitizeOptions(options); + expect(result.ac[0]).toBe("normal criteria"); + expect(result.ac[1]).toBe("[command substitution detected - please escape backticks]"); + expect(result.ac[2]).toBe("another normal one"); + }); + + test("should handle mixed option types", () => { + const options = { + description: "Enter your name:", + plan: "Normal plan text", + notes: undefined, + labels: ["label1", "label2"], + }; + const result = sanitizeOptions(options); + expect(result.description).toBe("[command substitution detected - please escape backticks]"); + expect(result.plan).toBe("Normal plan text"); + expect(result.notes).toBe(undefined); + expect(result.labels).toEqual(["label1", "label2"]); + }); +}); diff --git a/src/utils/sanitize-backticks.ts b/src/utils/sanitize-backticks.ts new file mode 100644 index 000000000..949f6bb69 --- /dev/null +++ b/src/utils/sanitize-backticks.ts @@ -0,0 +1,64 @@ +/** + * Dirty hack to clean up common command substitution accidents + * When users accidentally have `backlog init` execute, we detect and clean it + */ + +// Pattern to detect if backlog commands were accidentally executed +const BACKLOG_COMMAND_OUTPUT = + /^(backlog\s+(init|task|board|config|draft|open|log|sync|archive|delete|update|move|comment)[\s\S]*)/i; + +// Pattern to detect common interactive prompt outputs +const PROMPT_PATTERNS = [/Select .+:/i, /Choose .+:/i, /Enter .+:/i, /Which .+ would you like/i, /Please select/i]; + +export function sanitizeBackticks(text: string | undefined): string | undefined { + if (!text) return text; + + // Check if the text contains output from a backlog command + if (BACKLOG_COMMAND_OUTPUT.test(text)) { + // Replace the entire command output with a placeholder message + return "[backticks were here but command was executed - please use single quotes to include literal backticks]"; + } + + // Check for interactive prompt patterns + for (const pattern of PROMPT_PATTERNS) { + if (pattern.test(text)) { + return "[command substitution detected - please escape backticks]"; + } + } + + return text; +} + +export function sanitizeOptions(options: Record): Record { + const sanitized = { ...options }; + + // Sanitize string options that might contain command substitution results + const fieldsToSanitize = [ + "description", + "desc", + "plan", + "notes", + "ac", + "acceptanceCriteria", + "title", + "label", + "labels", + "addLabel", + "removeLabel", + "appendNotes", + ]; + + for (const field of fieldsToSanitize) { + if (sanitized[field]) { + if (typeof sanitized[field] === "string") { + sanitized[field] = sanitizeBackticks(sanitized[field]); + } else if (Array.isArray(sanitized[field])) { + sanitized[field] = sanitized[field].map((item: any) => + typeof item === "string" ? sanitizeBackticks(item) : item, + ); + } + } + } + + return sanitized; +}