diff --git a/src/adapters/command-code-project-context.ts b/src/adapters/command-code-project-context.ts index 50b2b5e9cf9..c79eff473c1 100644 --- a/src/adapters/command-code-project-context.ts +++ b/src/adapters/command-code-project-context.ts @@ -1,6 +1,6 @@ import { constants } from "node:fs"; import { lstat, open, opendir, realpath, stat } from "node:fs/promises"; -import { isAbsolute, join, relative, sep } from "node:path"; +import { join, sep } from "node:path"; export type CommandCodeProjectContext = { memory: string; @@ -140,14 +140,11 @@ async function canonicalPath(candidate: string, deadline: number, scope: ScanSco } } -function normalizePathIdentity(path: string): string { - return process.platform === "win32" ? path.toLowerCase() : path; -} - -/** Relative paths also work when cwd is a filesystem root; other volumes remain outside. */ -export function isContainedCanonicalPath(cwdCanonical: string, fileCanonical: string): boolean { - const rel = relative(normalizePathIdentity(cwdCanonical), normalizePathIdentity(fileCanonical)); - return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)); +/** Exact canonical prefixes preserve case-sensitive Windows directory identities. */ +export function isContainedCanonicalPath(cwdCanonical: string, fileCanonical: string, separator = sep): boolean { + if (fileCanonical === cwdCanonical) return true; + const prefix = cwdCanonical.endsWith(separator) ? cwdCanonical : `${cwdCanonical}${separator}`; + return fileCanonical.startsWith(prefix); } async function confinedCanonicalPath( @@ -199,8 +196,7 @@ async function openedFileIsConfined( const resolved = await withinDeadline(() => realpath(path), deadline, scope); // The input path was canonical before open. A changed intermediate symlink changes this // result even though O_NOFOLLOW protects only the final component on macOS and Linux. - if (!isContainedCanonicalPath(cwdCanonical, resolved) - || normalizePathIdentity(resolved) !== normalizePathIdentity(path)) return false; + if (!isContainedCanonicalPath(cwdCanonical, resolved) || resolved !== path) return false; const resolvedInfo = await withinDeadline(() => lstat(resolved), deadline, scope); return resolvedInfo.isFile() && opened.dev === resolvedInfo.dev && opened.ino === resolvedInfo.ino; } diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 24d10b72046..53e94ff55e0 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -257,8 +257,9 @@ and `skills`, and leaves existing `config` metadata unchanged without invoking `src/adapters/command-code-project-context.ts`. The loader reads only the proxy process working directory's `AGENTS.md`, `.commandcode/taste/taste.md`, and immediate child `SKILL.md` files under `.commandcode/skills`, `.agents/skills`, and `.pi/skills`. -Asynchronous path checks share one deadline and use relative-path containment even at -filesystem roots. On macOS/Linux a nonblocking, no-follow open is followed by file-inode +Asynchronous path checks share one deadline and require an exact, case-preserving canonical +path prefix, including at filesystem roots. On macOS/Linux a nonblocking, no-follow open is +followed by file-inode comparison and fresh canonical containment checks before and after reading; an intermediate directory replaced by an outside symlink cannot publish its file contents. Windows applies the path and identity checks as best effort. Every visited directory entry consumes the diff --git a/tests/providers/command-code-project-context.test.ts b/tests/providers/command-code-project-context.test.ts index 3774cee8c3b..cbb246aa114 100644 --- a/tests/providers/command-code-project-context.test.ts +++ b/tests/providers/command-code-project-context.test.ts @@ -85,7 +85,7 @@ describe("loadCommandCodeProjectContext", () => { expect(result).toEqual(EMPTY_COMMAND_CODE_PROJECT_CONTEXT); }); - test("relative containment includes descendants of a filesystem root", () => { + test("canonical containment includes descendants of a filesystem root", () => { const fsRoot = parse(tmpdir()).root; expect(isContainedCanonicalPath(fsRoot, join(fsRoot, "AGENTS.md"))).toBe(true); const nested = join(fsRoot, "project"); @@ -93,6 +93,11 @@ describe("loadCommandCodeProjectContext", () => { expect(isContainedCanonicalPath(nested, join(fsRoot, "project-sibling", "SKILL.md"))).toBe(false); }); + test("Windows containment preserves case-sensitive directory identities", () => { + expect(isContainedCanonicalPath("C:\\work\\project", "C:\\work\\project\\AGENTS.md", "\\")).toBe(true); + expect(isContainedCanonicalPath("C:\\work\\project", "C:\\work\\PROJECT\\secret.txt", "\\")).toBe(false); + }); + test("stalled asynchronous path metadata obeys the overall deadline", async () => { const root = makeTempDir("ocx-cc-ctx-metadata-timeout-"); const agentsPath = join(root, "AGENTS.md"); @@ -699,6 +704,35 @@ describe("loadCommandCodeProjectContext", () => { } }); + test("rejects a file whose post-open canonical path changes case", async () => { + // Windows-only delta: the post-open check compares the resolved path to the + // pre-open canonical path byte-for-byte now. Containment alone cannot catch a + // case-only rename because the parent prefix stays identical, so a filename + // whose realpath changes case between canonicalization and open is refused. + if (process.platform !== "win32") return; + const root = makeTempDir("ocx-cc-ctx-case-swap-"); + const skillFile = join(root, ".commandcode", "skills", "case-skill", "SKILL.md"); + try { + writeSkill(root, ".commandcode/skills", "case-skill", "case body"); + setCommandCodeBeforeOpenForTests(path => { + if (path !== skillFile) return; + realpathMock.mockImplementation(async (p, opts) => { + const resolved = await realRealpath(p, opts); + return typeof resolved === "string" && resolved === skillFile + ? resolved.replace(/SKILL\.md$/, "skill.md") + : resolved; + }); + }); + const result = await loadCommandCodeProjectContext(root); + expect(result.skills).toBeNull(); + expect(JSON.stringify(result)).not.toContain("case body"); + } finally { + setCommandCodeBeforeOpenForTests(undefined); + realpathMock.mockImplementation(realRealpath); + rmSync(root, { recursive: true, force: true }); + } + }); + test("omits unreadable AGENTS.md when chmod is enforced", async () => { if (process.platform === "win32") return; const root = makeTempDir("ocx-cc-ctx-unreadable-");