Skip to content

Commit 1a65ec5

Browse files
feat(security): introduce WorkspaceFileAccess — central workspace boundary authorization (#390)
Sub-issue 2 of #169, building on WorkspacePathResolver (#389/#428). Adds src/core/workspace/WorkspaceFileAccess.ts. `authorizeRead`/`authorizeWrite` return either `{ ok: true, resolvedPath }` or a structured `{ ok: false, reason }` (outside_workspace | symlink_escapes_workspace | realpath_failed | permission_denied), so tools can no longer perform a boolean check and then act on a stale path — the missed `ApplyDiffTool` check in #241 is exactly the failure mode this prevents. Default behavior canonicalizes the requested path and every workspace folder via `resolveRealPath` and fails closed; the `allowSymlinksOutsideWorkspace` opt-in restores the pre-#169 lexical behavior. The setting is introduced here (its home) in GlobalSettings and ExtensionState. No tool migrations, UI, or i18n in this PR.
1 parent a3f6b40 commit 1a65ec5

4 files changed

Lines changed: 361 additions & 0 deletions

File tree

packages/types/src/global-settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ export const globalSettingsSchema = z.object({
101101
alwaysAllowWrite: z.boolean().optional(),
102102
alwaysAllowWriteOutsideWorkspace: z.boolean().optional(),
103103
alwaysAllowWriteProtected: z.boolean().optional(),
104+
allowSymlinksOutsideWorkspace: z.boolean().optional(),
104105
writeDelayMs: z.number().min(0).optional(),
105106
requestDelaySeconds: z.number().optional(),
106107
alwaysAllowMcp: z.boolean().optional(),

packages/types/src/vscode-extension-host.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ export type ExtensionState = Pick<
252252
| "alwaysAllowWrite"
253253
| "alwaysAllowWriteOutsideWorkspace"
254254
| "alwaysAllowWriteProtected"
255+
| "allowSymlinksOutsideWorkspace"
255256
| "alwaysAllowMcp"
256257
| "alwaysAllowModeSwitch"
257258
| "alwaysAllowSubtasks"
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import * as path from "path"
2+
3+
import * as vscode from "vscode"
4+
5+
import type { Task } from "../task/Task"
6+
import { resolveRealPath } from "../../utils/WorkspacePathResolver"
7+
8+
/**
9+
* Why an authorization was denied. Distinguishing `symlink_escapes_workspace` from
10+
* `outside_workspace` lets callers (and telemetry) tell a deliberate out-of-tree path apart from a
11+
* path that *looks* inside the workspace but resolves out of it via a symlink (#169).
12+
*/
13+
export type AuthorizeDenyReason =
14+
| "outside_workspace"
15+
| "symlink_escapes_workspace"
16+
| "realpath_failed"
17+
| "permission_denied"
18+
19+
export type AuthorizeResult =
20+
| { ok: true; resolvedPath: string }
21+
| { ok: false; reason: AuthorizeDenyReason; message: string }
22+
23+
export interface AuthorizeOptions {
24+
/** Task whose provider state supplies the `allowSymlinksOutsideWorkspace` opt-in. */
25+
task: Task
26+
/** Absolute path as supplied by the tool. */
27+
requestedPath: string
28+
/** Tool name, used to prefix error messages. */
29+
source: string
30+
}
31+
32+
/** Lowercase on case-insensitive filesystems, matching {@link resolveRealPath}'s own normalization. */
33+
function normalizeCase(p: string): string {
34+
return process.platform === "darwin" || process.platform === "win32" ? p.toLowerCase() : p
35+
}
36+
37+
/** True when `child` is `parent` itself or nested under it. Both paths must already be normalized. */
38+
function isContained(parent: string, child: string): boolean {
39+
return child === parent || child.startsWith(parent + path.sep)
40+
}
41+
42+
/**
43+
* Read the `allowSymlinksOutsideWorkspace` opt-in from provider state, defaulting to `false`
44+
* (fail-closed). The provider may have been torn down mid-operation, in which case `getState()`
45+
* rejects — that must not abort the file operation, so we swallow it and keep the safe default.
46+
*/
47+
async function readAllowSymlinksOutsideWorkspace(task: Task): Promise<boolean> {
48+
try {
49+
return (await task.providerRef.deref()?.getState())?.allowSymlinksOutsideWorkspace ?? false
50+
} catch {
51+
return false
52+
}
53+
}
54+
55+
/**
56+
* Central authorization for file access against the workspace boundary.
57+
*
58+
* Tools call this instead of doing a raw `isPathOutsideWorkspace()` boolean check followed by their
59+
* own `fs` operation. The decoupled check-then-act pattern is structurally easy to get wrong — one
60+
* missed call site (like `ApplyDiffTool` in #241) leaves a hole. Here a tool requests an authorized
61+
* operation and gets back either a resolved path it may use, or a structured error it must surface.
62+
*
63+
* Default (fail-closed) behavior follows symlinks: the requested path and every workspace folder are
64+
* canonicalized via {@link resolveRealPath}, and access is granted only when the real path lands
65+
* inside a real workspace folder. When the user opts in via `allowSymlinksOutsideWorkspace`, the
66+
* pre-#169 lexical behavior is restored (symlinks are not followed for the boundary decision).
67+
*
68+
* This layer owns policy only — it performs no `fs` read/write itself.
69+
*/
70+
async function authorize(options: AuthorizeOptions): Promise<AuthorizeResult> {
71+
const { task, requestedPath, source } = options
72+
const allowSymlinksOutsideWorkspace = await readAllowSymlinksOutsideWorkspace(task)
73+
74+
// Canonicalize the requested path (follows symlinks; walks up to the nearest existing ancestor
75+
// for not-yet-created files). A non-ENOENT error here means we can't prove anything about the
76+
// path, so we fail closed with a structured reason.
77+
let resolvedPath: string
78+
try {
79+
resolvedPath = await resolveRealPath(requestedPath)
80+
} catch (err) {
81+
const code = (err as NodeJS.ErrnoException)?.code
82+
const detail = err instanceof Error ? err.message : String(err)
83+
if (code === "EACCES" || code === "EPERM") {
84+
return {
85+
ok: false,
86+
reason: "permission_denied",
87+
message: `[${source}] Permission denied resolving ${requestedPath}: ${detail}`,
88+
}
89+
}
90+
return {
91+
ok: false,
92+
reason: "realpath_failed",
93+
message: `[${source}] Could not resolve real path for ${requestedPath}: ${detail}`,
94+
}
95+
}
96+
97+
const folders = vscode.workspace.workspaceFolders ?? []
98+
99+
// Opt-in: restore pre-#169 lexical behavior — compare the literal path, never the symlink target.
100+
if (allowSymlinksOutsideWorkspace) {
101+
const lexicalPath = normalizeCase(path.resolve(requestedPath))
102+
const insideLexically = folders.some((folder) =>
103+
isContained(normalizeCase(path.resolve(folder.uri.fsPath)), lexicalPath),
104+
)
105+
if (insideLexically) {
106+
return { ok: true, resolvedPath }
107+
}
108+
return {
109+
ok: false,
110+
reason: "outside_workspace",
111+
message: `[${source}] ${requestedPath} is outside the workspace.`,
112+
}
113+
}
114+
115+
// Fail-closed: with no workspace open nothing can be proven inside.
116+
if (folders.length === 0) {
117+
return {
118+
ok: false,
119+
reason: "outside_workspace",
120+
message: `[${source}] No workspace folder is open; ${requestedPath} cannot be authorized.`,
121+
}
122+
}
123+
124+
// Compare the resolved path against each *resolved* workspace folder. A folder we can't resolve
125+
// (e.g. permissions) can't prove containment, so it's skipped rather than treated as a match.
126+
for (const folder of folders) {
127+
let resolvedFolder: string
128+
try {
129+
resolvedFolder = await resolveRealPath(folder.uri.fsPath)
130+
} catch {
131+
continue
132+
}
133+
if (isContained(resolvedFolder, resolvedPath)) {
134+
return { ok: true, resolvedPath }
135+
}
136+
}
137+
138+
// Outside every folder. If the literal path looked inside but the resolved path escaped, a
139+
// symlink is responsible — surface that distinctly from a plainly out-of-workspace path.
140+
const lexicalPath = normalizeCase(path.resolve(requestedPath))
141+
const lexicallyInside = folders.some((folder) =>
142+
isContained(normalizeCase(path.resolve(folder.uri.fsPath)), lexicalPath),
143+
)
144+
if (lexicallyInside) {
145+
return {
146+
ok: false,
147+
reason: "symlink_escapes_workspace",
148+
message: `[${source}] ${requestedPath} resolves via symlink to ${resolvedPath}, which is outside the workspace.`,
149+
}
150+
}
151+
return {
152+
ok: false,
153+
reason: "outside_workspace",
154+
message: `[${source}] ${requestedPath} is outside the workspace.`,
155+
}
156+
}
157+
158+
/** Authorize a read against the workspace boundary. See {@link authorize}. */
159+
export function authorizeRead(options: AuthorizeOptions): Promise<AuthorizeResult> {
160+
return authorize(options)
161+
}
162+
163+
/** Authorize a write against the workspace boundary. See {@link authorize}. */
164+
export function authorizeWrite(options: AuthorizeOptions): Promise<AuthorizeResult> {
165+
return authorize(options)
166+
}
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import * as os from "os"
2+
import * as path from "path"
3+
import * as fs from "fs/promises"
4+
5+
import * as vscode from "vscode"
6+
7+
import type { Task } from "../../task/Task"
8+
import { authorizeRead, authorizeWrite } from "../WorkspaceFileAccess"
9+
10+
vi.mock("vscode", () => ({
11+
workspace: { workspaceFolders: [] as { uri: { fsPath: string } }[] },
12+
}))
13+
14+
// Real symlinks in a real temp directory (no fs mocking, per #389/#390). Some scenarios can't be
15+
// reproduced everywhere: symlink creation needs privileges on Windows, and chmod-based EACCES is
16+
// meaningless as root. Such cases are skipped at runtime rather than mocked.
17+
const isWindows = process.platform === "win32"
18+
const isRoot = typeof process.getuid === "function" && process.getuid() === 0
19+
20+
/** Lowercase on case-insensitive filesystems, matching the resolver's own normalization. */
21+
const expectCase = (p: string) => (process.platform === "darwin" || process.platform === "win32" ? p.toLowerCase() : p)
22+
23+
/** Minimal Task stub exposing only what WorkspaceFileAccess reads. */
24+
function makeTask(opts: { allow?: boolean; providerGone?: boolean; getStateThrows?: boolean } = {}): Task {
25+
const provider = {
26+
getState: async () => {
27+
if (opts.getStateThrows) {
28+
throw new Error("provider torn down")
29+
}
30+
return { allowSymlinksOutsideWorkspace: opts.allow }
31+
},
32+
}
33+
return {
34+
providerRef: { deref: () => (opts.providerGone ? undefined : provider) },
35+
} as unknown as Task
36+
}
37+
38+
function setWorkspaceFolders(...paths: string[]) {
39+
;(vscode.workspace as any).workspaceFolders = paths.map((p) => ({ uri: { fsPath: p } }))
40+
}
41+
42+
describe("WorkspaceFileAccess", () => {
43+
let tmpRoot: string
44+
let workspace: string
45+
let outside: string
46+
let symlinksSupported = false
47+
48+
beforeEach(async () => {
49+
// realpath the temp root so comparisons aren't tripped up by /var -> /private/var (macOS).
50+
tmpRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "zoo-wfa-")))
51+
workspace = path.join(tmpRoot, "workspace")
52+
outside = path.join(tmpRoot, "outside")
53+
await fs.mkdir(workspace, { recursive: true })
54+
await fs.mkdir(outside, { recursive: true })
55+
setWorkspaceFolders(workspace)
56+
57+
const probeTarget = path.join(tmpRoot, "probe-target")
58+
const probeLink = path.join(tmpRoot, "probe-link")
59+
await fs.writeFile(probeTarget, "probe")
60+
try {
61+
await fs.symlink(probeTarget, probeLink)
62+
symlinksSupported = true
63+
} catch {
64+
symlinksSupported = false
65+
}
66+
})
67+
68+
afterEach(async () => {
69+
await fs.chmod(path.join(workspace, "restricted"), 0o755).catch(() => {})
70+
await fs.rm(tmpRoot, { recursive: true, force: true }).catch(() => {})
71+
;(vscode.workspace as any).workspaceFolders = []
72+
})
73+
74+
it("authorizes a real file inside the workspace and returns its canonical path", async () => {
75+
const file = path.join(workspace, "file.txt")
76+
await fs.writeFile(file, "x")
77+
78+
const result = await authorizeRead({ task: makeTask(), requestedPath: file, source: "read_file" })
79+
80+
expect(result.ok).toBe(true)
81+
if (result.ok) {
82+
expect(result.resolvedPath).toBe(expectCase(await fs.realpath(file)))
83+
}
84+
})
85+
86+
it("denies a symlink inside the workspace that escapes it (symlink_escapes_workspace)", async () => {
87+
if (!symlinksSupported) return
88+
const secret = path.join(outside, "secret.txt")
89+
await fs.writeFile(secret, "x")
90+
const link = path.join(workspace, "link.txt")
91+
await fs.symlink(secret, link)
92+
93+
const result = await authorizeRead({ task: makeTask(), requestedPath: link, source: "read_file" })
94+
95+
expect(result).toMatchObject({ ok: false, reason: "symlink_escapes_workspace" })
96+
})
97+
98+
it("allows an escaping symlink when allowSymlinksOutsideWorkspace is true", async () => {
99+
if (!symlinksSupported) return
100+
const secret = path.join(outside, "secret.txt")
101+
await fs.writeFile(secret, "x")
102+
const link = path.join(workspace, "link.txt")
103+
await fs.symlink(secret, link)
104+
105+
const result = await authorizeRead({ task: makeTask({ allow: true }), requestedPath: link, source: "read_file" })
106+
107+
expect(result.ok).toBe(true)
108+
if (result.ok) {
109+
expect(result.resolvedPath).toBe(expectCase(await fs.realpath(secret)))
110+
}
111+
})
112+
113+
it("denies a path that is plainly outside the workspace (outside_workspace)", async () => {
114+
const file = path.join(outside, "file.txt")
115+
await fs.writeFile(file, "x")
116+
117+
const result = await authorizeRead({ task: makeTask(), requestedPath: file, source: "read_file" })
118+
119+
expect(result).toMatchObject({ ok: false, reason: "outside_workspace" })
120+
})
121+
122+
it("returns permission_denied when the path cannot be resolved due to EACCES", async () => {
123+
if (isWindows || isRoot) return
124+
const restricted = path.join(workspace, "restricted")
125+
await fs.mkdir(restricted)
126+
const target = path.join(restricted, "file.txt")
127+
await fs.writeFile(target, "x")
128+
await fs.chmod(restricted, 0o000)
129+
130+
const result = await authorizeRead({ task: makeTask(), requestedPath: target, source: "read_file" })
131+
132+
expect(result).toMatchObject({ ok: false, reason: "permission_denied" })
133+
})
134+
135+
it("authorizes a not-yet-created file under a symlinked ancestor that stays inside the workspace", async () => {
136+
if (!symlinksSupported) return
137+
const realDir = path.join(workspace, "real-dir")
138+
await fs.mkdir(realDir)
139+
const linkDir = path.join(workspace, "link-dir")
140+
await fs.symlink(realDir, linkDir)
141+
const newFile = path.join(linkDir, "not-created-yet.txt")
142+
143+
const result = await authorizeWrite({ task: makeTask(), requestedPath: newFile, source: "write_to_file" })
144+
145+
expect(result.ok).toBe(true)
146+
if (result.ok) {
147+
expect(result.resolvedPath).toBe(expectCase(path.join(await fs.realpath(realDir), "not-created-yet.txt")))
148+
}
149+
})
150+
151+
it("fails closed when the provider has been torn down (deref returns undefined)", async () => {
152+
if (!symlinksSupported) return
153+
const secret = path.join(outside, "secret.txt")
154+
await fs.writeFile(secret, "x")
155+
const link = path.join(workspace, "link.txt")
156+
await fs.symlink(secret, link)
157+
158+
// providerGone => allowSymlinksOutsideWorkspace defaults to false => symlink is followed and blocked.
159+
const result = await authorizeRead({
160+
task: makeTask({ providerGone: true }),
161+
requestedPath: link,
162+
source: "read_file",
163+
})
164+
165+
expect(result).toMatchObject({ ok: false, reason: "symlink_escapes_workspace" })
166+
})
167+
168+
it("fails closed when reading provider state throws", async () => {
169+
if (!symlinksSupported) return
170+
const secret = path.join(outside, "secret.txt")
171+
await fs.writeFile(secret, "x")
172+
const link = path.join(workspace, "link.txt")
173+
await fs.symlink(secret, link)
174+
175+
const result = await authorizeRead({
176+
task: makeTask({ getStateThrows: true }),
177+
requestedPath: link,
178+
source: "read_file",
179+
})
180+
181+
expect(result).toMatchObject({ ok: false, reason: "symlink_escapes_workspace" })
182+
})
183+
184+
it("fails closed when no workspace folder is open", async () => {
185+
;(vscode.workspace as any).workspaceFolders = []
186+
const file = path.join(workspace, "file.txt")
187+
await fs.writeFile(file, "x")
188+
189+
const result = await authorizeWrite({ task: makeTask(), requestedPath: file, source: "write_to_file" })
190+
191+
expect(result).toMatchObject({ ok: false, reason: "outside_workspace" })
192+
})
193+
})

0 commit comments

Comments
 (0)