Skip to content

Commit 026a0f6

Browse files
Merge pull request #516 from corbitsdev/cl-6712-return-canonical-paths-from-workspace-containment-allows
Return canonical realpath from workspace containment allow
2 parents cbae96a + f3f8bb4 commit 026a0f6

6 files changed

Lines changed: 151 additions & 13 deletions

File tree

‎src/permission/path-restriction.ts‎

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ function realpathOr(path: string): string {
4444
// the raw (possibly symlink-relative) path, which would defeat containment
4545
// checks whenever the workspace root itself is reached through a symlink
4646
// (e.g. macOS's /tmp -> /private/tmp).
47-
function realpathNearestOr(path: string): string {
47+
export function realpathNearestOr(path: string): string {
4848
try {
4949
return realpathSync(path);
5050
} catch {
@@ -68,9 +68,18 @@ const inKnownRoots = (real: string, roots: readonly string[]): boolean =>
6868
// Resolves `path` (relative or absolute, possibly traversing `..`) against
6969
// `cwd` and checks it against the workspace boundary: `cwd` itself plus every
7070
// root `rootsProvider` reports (the session's registered git worktrees, or
71-
// any other allowlisted sibling). Returns the resolved absolute path when the
72-
// target is in bounds, `undefined` otherwise — callers that need a hard
73-
// allow/deny (rather than an allow/ask distinction) can key off that.
71+
// any other allowlisted sibling). Returns the CANONICAL real path (symlink
72+
// segments resolved; for a not-yet-created target, the nearest existing
73+
// ancestor's real path rejoined with the missing tail) when the target is in
74+
// bounds, `undefined` otherwise — callers that need a hard allow/deny (rather
75+
// than an allow/ask distinction) can key off that.
76+
//
77+
// Returning the canonical path rather than the lexical `abs` closes a
78+
// TOCTOU: a symlink segment that is in-bounds at check time can be
79+
// retargeted before a write actually happens. Callers (e.g. pathEscapePlugin)
80+
// substitute this return value into the tool call's path argument, so the
81+
// writer that ultimately opens the file never re-traverses the original
82+
// symlink — it uses the already-resolved location.
7483
//
7584
// A relative `../` is deliberately resolved and realpath-checked against the
7685
// allowlist rather than rejected outright: the raw path alone can't tell a
@@ -84,9 +93,9 @@ export function resolveWorkspacePath(
8493
const abs = resolve(cwd, path);
8594
const realCwd = realpathOr(resolve(cwd));
8695
const real = realpathNearestOr(abs);
87-
if (real === realCwd || real.startsWith(realCwd + sep)) return abs;
88-
if (inKnownRoots(real, rootsProvider())) return abs;
89-
if (inKnownRoots(real, rootsProvider(true))) return abs;
96+
if (real === realCwd || real.startsWith(realCwd + sep)) return real;
97+
if (inKnownRoots(real, rootsProvider())) return real;
98+
if (inKnownRoots(real, rootsProvider(true))) return real;
9099
return undefined;
91100
}
92101

‎src/permission/permission.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2813,7 +2813,10 @@ describe("listWorktreeRoots", () => {
28132813
const { repo, worktree } = createRepoWithWorktree();
28142814
const roots = await listWorktreeRoots(repo);
28152815
const relativeTarget = join("..", "secondary", "notes.md");
2816-
expect(resolveWorkspacePath(repo, relativeTarget, () => roots)).toBe(join(repo, "..", "secondary", "notes.md"));
2816+
// Canonical (realpath-resolved), not the lexical join — see CL-6712.
2817+
expect(resolveWorkspacePath(repo, relativeTarget, () => roots)).toBe(
2818+
join(realpathSync(join(repo, "..")), "secondary", "notes.md"),
2819+
);
28172820
});
28182821

28192822
test("resolveWorkspacePath still rejects a genuinely unrelated outside path", async () => {

‎src/permission/workspace-containment.test.ts‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { tmpdir } from "node:os";
66
import type { ToolCall } from "@intx/types/runtime";
77

88
import { isAutoAllowedShellCall } from "./classify.js";
9-
import { createPathRestriction } from "./path-restriction.js";
9+
import { createPathRestriction, resolveWorkspacePath } from "./path-restriction.js";
1010

1111
let cwd = "";
1212
let worktree = "";
@@ -91,3 +91,34 @@ test("a symlink pointing outside the workspace is refused, even for a not-yet-ex
9191
expect(autoAllowed).toBe(false);
9292
expect(restricted).toBe(true);
9393
});
94+
95+
test("resolveWorkspacePath returns the canonical target so a later symlink retarget cannot redirect a write (CL-6712 TOCTOU)", async () => {
96+
// A symlink that is in-bounds at check time (target -> inside cwd) must
97+
// resolve to the canonical real path, not the lexical path through the
98+
// symlink. If the caller only remembered the lexical path and re-opened it
99+
// after the symlink is retargeted, the write would follow the new target
100+
// instead of the one that was actually approved.
101+
const rootsProvider = () => [];
102+
const realTarget = join(cwd, "real-target");
103+
await mkdir(realTarget, { recursive: true });
104+
const link = join(cwd, "link");
105+
await symlink(realTarget, link);
106+
107+
const resolved = resolveWorkspacePath(cwd, join("link", "note.txt"), rootsProvider);
108+
expect(resolved).toBe(join(realpathSync(realTarget), "note.txt"));
109+
110+
// Retarget the symlink to point outside the workspace, as an attacker
111+
// would do between the allow check and the actual write.
112+
await rm(link);
113+
await symlink(outside, link);
114+
115+
// The canonical path captured before the retarget still points at the
116+
// originally-approved location inside the workspace — it never traverses
117+
// "link" again, so it is unaffected by the retarget.
118+
expect(resolved).not.toContain(outside);
119+
120+
// A fresh check against the now-retargeted symlink correctly sees the
121+
// escape and denies it.
122+
const restriction = createPathRestriction(cwd, rootsProvider, home);
123+
expect(restriction.isRestricted(join(link, "note.txt"), true)).toBe(true);
124+
});

‎src/permission/write-path-policy.test.ts‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { describe, expect, test } from "bun:test";
2-
import { resolve } from "node:path";
2+
import { mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join, resolve } from "node:path";
35
import {
46
matchesWritePathAllowlist,
57
writePathDeniedReason,
@@ -58,6 +60,39 @@ describe("matchesWritePathAllowlist", () => {
5860
});
5961
});
6062

63+
describe("matchesWritePathAllowlist with a symlinked cwd", () => {
64+
test("allows a write under the canonical target of a symlinked cwd", () => {
65+
// Mirrors macOS's /tmp -> /private/tmp: cwd is spelled via the symlink,
66+
// but resolveWorkspacePath (and any tool arg it rewrites) hands the
67+
// subject in already realpathed. Both sides of the compare must
68+
// canonicalize the same way or a legitimate write is hard-denied.
69+
const real = mkdtempSync(join(realpathSync(tmpdir()), "write-path-real-"));
70+
const linkDir = join(realpathSync(tmpdir()), `write-path-link-${process.pid}`);
71+
try {
72+
symlinkSync(real, linkDir);
73+
const symlinkedCwd = linkDir; // lexically distinct from `real`
74+
const canonicalSubject = join(real, "docs", "a.md"); // already realpathed
75+
76+
expect(
77+
matchesWritePathAllowlist(canonicalSubject, ["docs/*"], symlinkedCwd),
78+
).toBe(true);
79+
80+
// A genuinely outside path is still denied.
81+
const outsideReal = mkdtempSync(join(realpathSync(tmpdir()), "write-path-outside-"));
82+
try {
83+
expect(
84+
matchesWritePathAllowlist(join(outsideReal, "docs", "a.md"), ["docs/*"], symlinkedCwd),
85+
).toBe(false);
86+
} finally {
87+
rmSync(outsideReal, { recursive: true, force: true });
88+
}
89+
} finally {
90+
rmSync(linkDir, { force: true });
91+
rmSync(real, { recursive: true, force: true });
92+
}
93+
});
94+
});
95+
6196
describe("writePathDeniedReason", () => {
6297
test("names allowlist and subject", () => {
6398
const reason = writePathDeniedReason("src/x.ts", ["PRODUCT.md", "docs/*"]);

‎src/permission/write-path-policy.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { resolve, sep } from "node:path";
22
import { matchesPattern } from "./matcher.js";
3+
import { realpathNearestOr } from "./path-restriction.js";
34

45
/**
56
* Director write-path allowlist (authz, not prompt policy).
@@ -29,8 +30,12 @@ export function matchesWritePathAllowlist(
2930
if (allowlist.length === 0) return false;
3031
if (subject.length === 0) return false;
3132

32-
const absCwd = resolve(cwd);
33-
const abs = resolve(cwd, subject);
33+
// Canonicalize both sides the same way path-restriction does: a symlinked
34+
// cwd (e.g. macOS /tmp -> /private/tmp) must not desync from a subject
35+
// already resolved to its realpath by resolveWorkspacePath, which would
36+
// otherwise hard-deny a legitimate allowlisted write.
37+
const absCwd = realpathNearestOr(resolve(cwd));
38+
const abs = realpathNearestOr(resolve(cwd, subject));
3439
let rel: string;
3540
if (abs === absCwd) {
3641
rel = ".";

‎src/plugins/path-escape-plugin.test.ts‎

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { describe, test, expect } from "bun:test";
1+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2+
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
3+
import { existsSync, realpathSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
26

37
import { pathEscapePlugin } from "./path-escape-plugin.js";
48
import type { ToolCall, ToolResult } from "@intx/types/runtime";
@@ -175,4 +179,55 @@ describe("pathEscapePlugin", () => {
175179
const args = JSON.parse(String(allowed.content)) as { path: string };
176180
expect(args.path).toBe("/other-repo/README.md");
177181
});
182+
183+
describe("symlink TOCTOU (CL-6712)", () => {
184+
let cwd = "";
185+
186+
beforeEach(async () => {
187+
cwd = await mkdtemp(join(tmpdir(), "corbits-path-escape-"));
188+
});
189+
190+
afterEach(async () => {
191+
await rm(cwd, { recursive: true, force: true });
192+
});
193+
194+
test("write_file receives the canonical path, unaffected by a later symlink retarget", async () => {
195+
const realTarget = join(cwd, "real-target");
196+
await mkdir(realTarget, { recursive: true });
197+
const link = join(cwd, "link");
198+
await symlink(realTarget, link);
199+
200+
const plugin = pathEscapePlugin(cwd);
201+
const next = async (call: ToolCall): Promise<ToolResult> => ({
202+
callId: call.id,
203+
content: JSON.stringify(call.arguments),
204+
});
205+
const handler = plugin.middleware ? plugin.middleware(next) : next;
206+
207+
const result = await handler(
208+
makeCall("write_file", { path: join("link", "note.txt"), content: "hi" }),
209+
new AbortController().signal,
210+
);
211+
const args = JSON.parse(String(result.content)) as { path: string };
212+
// The path handed to write_file is already the resolved real-target
213+
// location, not the symlink-relative path.
214+
expect(args.path).toBe(join(realpathSync(realTarget), "note.txt"));
215+
216+
// An attacker retargets the symlink after the allow check. A writer
217+
// that (correctly) uses the path it was given above is unaffected —
218+
// it never re-traverses "link".
219+
const outside = await mkdtemp(join(tmpdir(), "corbits-path-escape-outside-"));
220+
await rm(link);
221+
await symlink(outside, link);
222+
expect(args.path).not.toContain(outside);
223+
224+
// A real writer using the resolved path lands the bytes at the
225+
// canonical (safe) location, never under the retargeted symlink.
226+
await writeFile(args.path, "hi");
227+
expect(await readFile(args.path, "utf8")).toBe("hi");
228+
expect(existsSync(join(outside, "note.txt"))).toBe(false);
229+
230+
await rm(outside, { recursive: true, force: true });
231+
});
232+
});
178233
});

0 commit comments

Comments
 (0)