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
17 changes: 16 additions & 1 deletion src/commands/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,18 @@ const SYNC_DIR = ".delega";
const SYNC_CONFIG = "config.json";
const TASKS_FILE = "tasks.jsonl";

// Server task ids are 32 lowercase hex chars. Locally authored records may have
// no id yet (created on push), but any id that *is* present must match.
const TASK_ID_RE = /^[a-f0-9]{32}$/;

export function taskPathSegment(id: string | number): string {
return encodeURIComponent(String(id));
const encoded = encodeURIComponent(String(id));
// encodeURIComponent leaves "." and ".." untouched, so an id of "." or ".."
// would still collapse a path via URL normalization. Refuse those outright.
if (encoded === "" || encoded === "." || encoded === "..") {
throw new Error(`Refusing to build a task path from unsafe id: ${JSON.stringify(String(id))}`);
}
return encoded;
}

function isPlainObject(value: unknown): value is Record<string, unknown> {
Expand Down Expand Up @@ -113,6 +123,11 @@ export function parseTasksJsonl(raw: string): TaskSyncRecord[] {
if (typeof parsed.content !== "string" || !parsed.content.trim()) {
throw new Error(`Line ${index + 1} must include content`);
}
if (parsed.id !== undefined && parsed.id !== null) {
if (typeof parsed.id !== "string" || !TASK_ID_RE.test(parsed.id)) {
throw new Error(`Line ${index + 1} has an invalid task id in .delega/tasks.jsonl`);
}
}
return parsed as unknown as TaskSyncRecord;
});
}
Expand Down
23 changes: 23 additions & 0 deletions test/sync.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,21 @@ test("parseTasksJsonl rejects non-object and content-less lines", () => {
assert.throws(() => parseTasksJsonl('{"id":"a"}\n'), /Line 1 must include content/);
});

test("parseTasksJsonl rejects path-confusing and malformed task ids", () => {
const valid = "a".repeat(32);
// A well-formed server id is accepted; absent id is allowed (created on push).
assert.equal(parseTasksJsonl(`{"id":"${valid}","content":"ok"}\n`)[0].id, valid);
assert.equal(parseTasksJsonl('{"content":"new local task"}\n')[0].id, undefined);
// Path-traversal / query-injection ids from a hostile tasks.jsonl are rejected.
for (const id of ["..", ".", "../agents", "abc/../def", "task?x=y", "AAAA", ""]) {
assert.throws(
() => parseTasksJsonl(`${JSON.stringify({ id, content: "x" })}\n`),
/Line 1 has an invalid task id/,
`id ${JSON.stringify(id)} should be rejected`,
);
}
});

test("diffSyncRecords separates local, remote, changed, and clean buckets", () => {
const diff = diffSyncRecords(
[
Expand All @@ -74,3 +89,11 @@ test("taskPathSegment encodes task IDs before URL path construction", () => {
assert.equal(taskPathSegment("../agents?x=y"), "..%2Fagents%3Fx%3Dy");
assert.equal(taskPathSegment("task/with?query=true"), "task%2Fwith%3Fquery%3Dtrue");
});

test("taskPathSegment refuses dot-segment ids that survive URL encoding", () => {
// encodeURIComponent leaves these untouched; URL normalization would then
// collapse "/tasks/../context" to "/context", so they must be rejected.
for (const id of [".", ".."]) {
assert.throws(() => taskPathSegment(id), /unsafe id/);
}
});