diff --git a/docs/CLI-TOOLS.md b/docs/CLI-TOOLS.md index ff979dbf0..0d863d6fe 100644 --- a/docs/CLI-TOOLS.md +++ b/docs/CLI-TOOLS.md @@ -1177,18 +1177,19 @@ from `todos/pending/` to `todos/completed/` and upserts `completed:` and `status: completed` inside the file's frontmatter block. Unknown flags are rejected loudly. -`` is a **basename inside the todos root**, not a path. A value that -resolves outside that root — a traversal like `../../escaped` or an embedded -separator like `sub/name.md` — is rejected as a usage error **before** any file -is read or moved (#4327). An absolute path is handled differently: it is -**folded under the todos root** (Node's `path.join` does not let a later -absolute segment escape a prior one), so it cannot reach a file outside the -root — it simply fails with the ordinary "Todo not found" error unless a file -of that joined name happens to exist under `todos/pending/`; it is not -rejected as a containment violation. The check covers both halves of the move, -so neither the source nor the destination can land outside the root, and -`--dry-run` is rejected on the same terms rather than previewing a resolved -outside path. +`` is a **basename inside the todos root**, not a path. A basename +guard runs first, before `` is joined onto any directory: a value +containing an embedded separator (either `/` or `\`, e.g. `sub/name.md` or +`sub\name.md`), a value whose own basename differs from itself (e.g. +`a/../../b.md`, `../sibling.md`), a bare `.` or `..`, an absolute path (e.g. +`/etc/passwd`), or a NUL byte is rejected as a usage error **before** any file +is read or moved (#4327, #4652). A traversal that only escapes the `pending`/ +`completed` subdirectory without leaving the todos root (`../sibling.md`) is +caught by this same guard, not by containment. Containment against the todos +root still runs afterward as defense-in-depth for the resolved source and +target paths, so neither half of the move can land outside the root. The +check covers both halves of the move, and `--dry-run` is rejected on the same +terms rather than previewing a resolved outside path. ```bash # UAT audit — scan all phases for unresolved items diff --git a/src/commands.cts b/src/commands.cts index 362fffd3b..663e02b39 100644 --- a/src/commands.cts +++ b/src/commands.cts @@ -3490,6 +3490,30 @@ function cmdTodoComplete(cwd: string, filename: string | undefined, options: Tod const todosRoot = todosDir(cwd); const pendingDir = path.join(todosRoot, 'pending'); const completedDir = path.join(todosRoot, 'completed'); + + // #4652: containment against todosRoot only rejects paths that leave the + // root — it cannot express "a todo name is a basename, not a path" (see + // #4327). `../sibling.md`, `a/../../b.md`, and `sub/name.md` all resolve + // to a location inside todosRoot (or inside pending/) and would pass + // containment, yet none of them is a bare filename. Reject on basename + // shape FIRST, before any path is even joined — same predicate shape as + // findPhaseArtifact in check-command-router.cts. Checking both `/` and + // `\` explicitly (not just path.basename) matters on POSIX, where a + // literal backslash is just an ordinary filename character to + // path.basename but not to path.win32.basename or to the user's intent. + const rawFilename = filename as string; + if ( + rawFilename === '.' || + rawFilename === '..' || + rawFilename.includes('\0') || + rawFilename.includes('/') || + rawFilename.includes('\\') || + path.basename(rawFilename) !== rawFilename || + path.win32.basename(rawFilename) !== rawFilename + ) { + error(`todo name must be a plain filename inside the pending directory, not a path: ${rawFilename}`, ERROR_REASON.USAGE); + } + const sourcePath = path.join(pendingDir, filename as string); const targetPath = path.join(completedDir, filename as string); @@ -3509,10 +3533,13 @@ function cmdTodoComplete(cwd: string, filename: string | undefined, options: Tod error(`Todo not found: ${filename as string}`); } - // #4652: `.` and `..` resolve to the pending dir itself (which IS inside - // todosRoot, so containment passes) but are not a todo file — reject them - // the same way as any other invalid name instead of letting - // fs.readFileSync throw an uncaught EISDIR with an absolute-path stack trace. + // #4652: a name that IS a bare basename can still resolve to something that + // is not a regular file — a directory, symlink-to-directory, FIFO or socket + // sitting in pending/ under an ordinary-looking name. `.` and `..` no longer + // reach here (the basename guard above rejects them first), so this is not + // about traversal; it stops fs.readFileSync from throwing an uncaught EISDIR + // with an absolute-path stack trace where every sibling case gives a clean + // USAGE rejection. if (!fs.statSync(resolvedSource).isFile()) { error(`todo name is not a file: ${filename as string}`, ERROR_REASON.USAGE); }