Skip to content
Open
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
4 changes: 0 additions & 4 deletions packages/opencode/src/file/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,8 +237,6 @@ export namespace File {
const project = Instance.project
const full = path.join(Instance.directory, file)

// TODO: Filesystem.contains is lexical only - symlinks inside the project can escape.
// TODO: On Windows, cross-drive paths bypass this check. Consider realpath canonicalization.
if (!Filesystem.contains(Instance.directory, full)) {
throw new Error(`Access denied: path escapes project directory`)
}
Expand Down Expand Up @@ -297,8 +295,6 @@ export namespace File {
}
const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory

// TODO: Filesystem.contains is lexical only - symlinks inside the project can escape.
// TODO: On Windows, cross-drive paths bypass this check. Consider realpath canonicalization.
if (!Filesystem.contains(Instance.directory, resolved)) {
throw new Error(`Access denied: path escapes project directory`)
}
Expand Down
33 changes: 32 additions & 1 deletion packages/opencode/src/util/filesystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,39 @@ export namespace Filesystem {
return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..")
}

/**
* Check if a relative path is contained (doesn't escape via .. or cross-drive)
*/
function isContained(rel: string): boolean {
// On Windows, check for cross-drive paths (e.g., "D:\..." from "C:\...")
if (process.platform === "win32" && /^[A-Za-z]:/.test(rel)) {
return false
}
return !rel.startsWith("..")
}

export function contains(parent: string, child: string) {
return !relative(parent, child).startsWith("..")
// Try to resolve each path individually to prevent symlink escapes.
// Use resolved paths when available for maximum security.
let resolvedParent = parent
let resolvedChild = child

try {
resolvedParent = realpathSync(parent)
} catch {
// Parent doesn't exist or can't be resolved, use original
}

try {
resolvedChild = realpathSync(child)
} catch {
// Child doesn't exist yet (common for new files), use original
// But if parent was resolved, still use resolved parent for safety
}

// Use the best available paths (resolved when possible)
const rel = relative(resolvedParent, resolvedChild)
return isContained(rel)
}

export async function findUp(target: string, start: string, stop?: string) {
Expand Down