Skip to content

Commit c378ee5

Browse files
committed
fix(permission): resolve ENOTDIR paths like realpathSync
lstat of sub/link/../file throws ENOTDIR when link points at a file. Walking that failure joined .. onto the file and returned the cwd file instead of the realpathSync result. ENOTDIR now follows realpathSync when that succeeds, and still walks when the tail is missing. A failed lstat of a relative path is no longer stored under the key path.resolve collapses . or .. onto. sub/link/. was remembered as absent for sub/link, so the symlink resolved in-tree and sub/link/./missing missed the target. Resolved paths are still not reused across calls.
1 parent 827f234 commit c378ee5

3 files changed

Lines changed: 99 additions & 11 deletions

File tree

‎src/permission/path-restriction.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import { dirname, join, resolve, sep } from "node:path";
33
import { homedir } from "node:os";
44
import { realpathOrOriginal, type RootsProvider } from "./worktree-roots.js";
55
import { projectSessionsRoot } from "../session/project-key.js";
6-
import { enterPathResolution, lstatMemoized } from "./realpath-cache.js";
6+
import {
7+
enterPathResolution,
8+
lstatFailureCode,
9+
lstatMemoized,
10+
} from "./realpath-cache.js";
711

812
// Paths the agent must not touch without explicit operator approval, even
913
// though read tools are otherwise allow-tier and write/edit auto-allow in
@@ -61,6 +65,15 @@ function resolveNearest(path: string): string {
6165
return realpathOrUnresolvable(path);
6266
}
6367

68+
// ENOTDIR is not absence. `sub/link/../file` lstats as ENOTDIR when `link` is
69+
// a symlink to a file, and the missing-tail walk then joins `..` onto that
70+
// file (landing on `<cwd>/file`). realpathSync still resolves it to `sub/file`.
71+
// When realpath also fails (a missing name under that component), keep walking.
72+
if (lstatFailureCode(path) === "ENOTDIR") {
73+
const real = realpathOrUnresolvable(path);
74+
if (real !== UNRESOLVABLE) return real;
75+
}
76+
6477
const parent = dirname(path);
6578
if (parent === path) return path;
6679
const parentReal = resolveNearest(parent);

‎src/permission/realpath-cache.test.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,41 @@ test("a relative single-segment path is not sliced when its dirname is dot", ()
5858
expect(resolved.endsWith(`${sep}cret.pem`)).toBe(false);
5959
});
6060

61+
test("dotdot through a symlink to a file matches realpathSync", async () => {
62+
scratch = await mkdtemp(join(tmpdir(), "corbits-realpath-dotdot-"));
63+
const sub = join(scratch, "sub");
64+
await mkdir(sub);
65+
await writeFile(join(scratch, "outside-target"), "t");
66+
await writeFile(join(sub, "file"), "f");
67+
await symlink(join(scratch, "outside-target"), join(sub, "link"));
68+
const through = `${join(sub, "link")}${sep}..${sep}file`;
69+
const canonical = realpathSync(through);
70+
expect(canonical).toBe(realpathSync(join(sub, "file")));
71+
expect(realpathNearestOr(through)).toBe(canonical);
72+
expect(realpathNearestOr(through)).not.toBe(
73+
join(realpathSync(scratch), "file"),
74+
);
75+
});
76+
77+
test("a failed dot lstat is not reused for the symlink it collapses onto", async () => {
78+
scratch = await mkdtemp(join(tmpdir(), "corbits-realpath-dot-"));
79+
const sub = join(scratch, "sub");
80+
await mkdir(sub);
81+
const outside = join(scratch, "outside-target");
82+
await writeFile(outside, "t");
83+
await symlink(outside, join(sub, "link"));
84+
const previous = process.cwd();
85+
process.chdir(scratch);
86+
try {
87+
expect(realpathNearestOr("sub/link/.")).toBe(realpathSync("sub/link/."));
88+
expect(realpathNearestOr("sub/link/./missing")).toBe(
89+
join(realpathSync(outside), "missing"),
90+
);
91+
} finally {
92+
process.chdir(previous);
93+
}
94+
});
95+
6196
test("a later check of the same path lstats and realpaths again", async () => {
6297
scratch = await mkdtemp(join(tmpdir(), "corbits-realpath-once-"));
6398
const ancestorA = join(scratch, "a");

‎src/permission/realpath-cache.ts‎

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import { lstatSync, statSync, type Stats } from "node:fs";
2-
import { isAbsolute, resolve } from "node:path";
2+
import { isAbsolute, resolve, sep } from "node:path";
33

44
// lstat results for one synchronous containment check (or one shell cwd
55
// resolve plus its usability stat). A resolved real path is never stored:
66
// the next tool call must walk again, because a symlink target can move
77
// without changing the link text or the leaf inode.
88
let depth = 0;
9-
let lstatMemo: Map<string, Stats | undefined> | undefined;
9+
let lstatMemo: Map<string, LstatMemoEntry> | undefined;
10+
11+
type LstatMemoEntry = {
12+
stats: Stats | undefined;
13+
code: string | undefined;
14+
};
1015

1116
export function enterPathResolution<T>(fn: () => T): T {
1217
const top = depth === 0;
@@ -24,18 +29,53 @@ function cacheKey(path: string): string {
2429
return isAbsolute(path) ? path : resolve(path);
2530
}
2631

27-
export function lstatMemoized(path: string): Stats | undefined {
32+
// `path.resolve` drops `.` and `..`, so `sub/link/.` and `sub/link` share a key.
33+
// A failed lstat of the dotted path is not "absent" for the symlink it collapses
34+
// onto (`..` through a symlink is a different node than the lexical parent).
35+
function failureCollapsesOntoAnotherPath(path: string): boolean {
36+
if (isAbsolute(path)) return false;
37+
for (const segment of path.split(sep)) {
38+
if (segment === "." || segment === "..") return true;
39+
}
40+
return false;
41+
}
42+
43+
function errorCode(error: unknown): string | undefined {
44+
if (typeof error !== "object" || error === null || !("code" in error)) {
45+
return undefined;
46+
}
47+
return typeof error.code === "string" ? error.code : undefined;
48+
}
49+
50+
function readLstat(path: string): LstatMemoEntry {
2851
const key = cacheKey(path);
2952
const memo = lstatMemo;
30-
if (memo !== undefined && memo.has(key)) return memo.get(key);
31-
let st: Stats | undefined;
53+
const cached = memo?.get(key);
54+
if (cached !== undefined) return cached;
55+
let entry: LstatMemoEntry;
3256
try {
33-
st = lstatSync(path);
34-
} catch {
35-
st = undefined;
57+
entry = { stats: lstatSync(path), code: undefined };
58+
} catch (error) {
59+
entry = { stats: undefined, code: errorCode(error) };
60+
}
61+
if (
62+
memo !== undefined &&
63+
!(entry.stats === undefined && failureCollapsesOntoAnotherPath(path))
64+
) {
65+
memo.set(key, entry);
3666
}
37-
memo?.set(key, st);
38-
return st;
67+
return entry;
68+
}
69+
70+
export function lstatMemoized(path: string): Stats | undefined {
71+
return readLstat(path).stats;
72+
}
73+
74+
// `code` is set only when lstat threw. ENOTDIR is not a missing path: the
75+
// component exists but is not a directory (a symlink to a file, then `.` or `..`).
76+
export function lstatFailureCode(path: string): string | undefined {
77+
const entry = readLstat(path);
78+
return entry.stats === undefined ? entry.code : undefined;
3979
}
4080

4181
/**

0 commit comments

Comments
 (0)