Skip to content
Closed
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
18 changes: 7 additions & 11 deletions src/adapters/command-code-project-context.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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;
}
Expand Down
5 changes: 3 additions & 2 deletions structure/providers-and-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 35 additions & 1 deletion tests/providers/command-code-project-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,14 +85,19 @@ 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");
expect(isContainedCanonicalPath(nested, join(nested, "..hidden"))).toBe(true);
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);
});
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

test("stalled asynchronous path metadata obeys the overall deadline", async () => {
const root = makeTempDir("ocx-cc-ctx-metadata-timeout-");
const agentsPath = join(root, "AGENTS.md");
Expand Down Expand Up @@ -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-");
Expand Down
Loading