Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -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: []
---
Expand Down
38 changes: 25 additions & 13 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -1334,18 +1340,22 @@ 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) {
console.error(`Task ${taskId} not found.`);
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)];
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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)
Expand Down
66 changes: 66 additions & 0 deletions src/test/sanitize-backticks.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
64 changes: 64 additions & 0 deletions src/utils/sanitize-backticks.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>): Record<string, any> {
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;
}
Loading