Skip to content

Commit 63aa657

Browse files
committed
Return canonical realpath from workspace containment allow
resolveWorkspacePath already computes each candidate's realpath to decide containment but returned the lexical path, leaving a TOCTOU window: a symlink in-bounds at allow time could be retargeted before the actual write, escaping the workspace. Return the resolved real path instead so pathEscapePlugin substitutes it into the tool call args, and writers act on the already-resolved location rather than re-traversing the symlink. Fixes CL-6712 https://linear.app/abklabs/issue/CL-6712
1 parent 1b22fa2 commit 63aa657

4 files changed

Lines changed: 100 additions & 9 deletions

File tree

‎src/permission/path-restriction.ts‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -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/plugins/path-escape-plugin.test.ts‎

Lines changed: 49 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, rm, symlink } from "node:fs/promises";
3+
import { 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,48 @@ 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+
await rm(outside, { recursive: true, force: true });
224+
});
225+
});
178226
});

0 commit comments

Comments
 (0)