fix: parse TaskList output without ambiguous regex (#1006)

## Summary

- Replace the ambiguous regex used to parse textual `TaskList` output
with linear string parsing.
- Preserve support for owners, multiple dependencies, and malformed
suffixes treated as subject text.
- Add a regression test covering adversarial input.

This addresses a potential ReDoS reported by CodeQL without changing the
structured JSON path.

Closes #1005.

## Testing

- `npm run test:run`
- `npm run build`
- `npm run check`
This commit is contained in:
José Gurruchaga
2026-09-14 09:18:15 -03:00
committed by GitHub
parent 15ba608a79
commit b274923ef8
2 changed files with 52 additions and 10 deletions

View File

@@ -2092,17 +2092,30 @@ describe("applyTaskCreate / applyTaskUpdate", () => {
it("parses the human-readable TaskList format used in session history", () => {
expect(
parseTaskListOutput(
"#1 [in_progress] Run tests\n#2 [pending] Write release notes [blocked by #1]",
"#1 [in_progress] Run tests (runner)\n#2 [pending] Write release notes [blocked by #1, #3]",
),
).toEqual({
tasks: [
{ id: "1", subject: "Run tests", status: "in_progress", blockedBy: [] },
{ id: "2", subject: "Write release notes", status: "pending", blockedBy: ["1"] },
{ id: "1", subject: "Run tests", status: "in_progress", owner: "runner", blockedBy: [] },
{
id: "2",
subject: "Write release notes",
status: "pending",
blockedBy: ["1", "3"],
},
],
});
expect(parseTaskListOutput("No tasks found")).toEqual({ tasks: [] });
});
it("handles adversarial TaskList output in linear time", () => {
const subject = `${"work [blocked by #1, ".repeat(20000)}work`;
expect(parseTaskListOutput(`#1 [pending] ${subject}`)).toEqual({
tasks: [{ id: "1", subject, status: "pending", blockedBy: [] }],
});
});
});
describe("toAcpNotifications - Task* tools", () => {

View File

@@ -1245,20 +1245,49 @@ export function parseTaskListOutput(content: unknown): TaskListOutput | undefine
const tasks: TaskListOutput["tasks"] = [];
const lines = text.trim().split("\n");
for (const line of lines) {
const match =
/^#(\S+) \[(pending|in_progress|completed)\] (.+?)(?: \(([^()]*)\))?(?: \[blocked by ((?:#[^,\]]+(?:, )?)+)\])?$/.exec(
line,
);
const match = /^#(\S+) \[(pending|in_progress|completed)\] (.+)$/.exec(line);
if (!match) {
tasks.length = 0;
break;
}
let subject = match[3];
let owner: string | undefined;
let blockedBy: string[] = [];
const blockedMarker = " [blocked by ";
const blockedStart = subject.lastIndexOf(blockedMarker);
if (blockedStart > 0 && subject.endsWith("]")) {
const dependencies = subject.slice(blockedStart + blockedMarker.length, -1).split(", ");
if (
dependencies.every(
(dependency) =>
dependency.length > 1 &&
dependency.startsWith("#") &&
!dependency.includes(",") &&
!dependency.includes("]"),
)
) {
subject = subject.slice(0, blockedStart);
blockedBy = dependencies.map((dependency) => dependency.slice(1));
}
}
const ownerStart = subject.lastIndexOf(" (");
if (ownerStart > 0 && subject.endsWith(")")) {
const candidate = subject.slice(ownerStart + 2, -1);
if (!candidate.includes("(") && !candidate.includes(")")) {
subject = subject.slice(0, ownerStart);
owner = candidate || undefined;
}
}
tasks.push({
id: match[1],
subject: match[3],
subject,
status: match[2] as TaskListOutput["tasks"][number]["status"],
...(match[4] ? { owner: match[4] } : {}),
blockedBy: match[5] ? match[5].split(", ").map((id) => id.slice(1)) : [],
...(owner ? { owner } : {}),
blockedBy,
});
}
if (tasks.length > 0) return { tasks };