siyuan-note/siyuan · error
symlink escapes workspace
Error message
symlink escapes workspace: %s
What it means
SiYuan's MCP file tools reject any path whose real location (after resolving symbolic links) leaves the workspace directory. authorizePath resolves the longest existing parent of the path with util.ResolveLongestExistingParent and compares it against util.WorkspaceDir using gulu.File.IsSubPath; a mismatch means a symlink (or a parent directory symlink) points outside the sandbox. This prevents MCP file tools from being used to read or write arbitrary files on the host via a planted symlink.
Solutions
- Remove the symlink inside the workspace and move the real data under the workspace directory, or replace it with a copy instead of a link
- Check where the link points (ls -l / readlink) and confirm whether the target should be inside the workspace; re-anchor it there
- If the link is intentional, expose the target through a supported mechanism (e.g. copy into data/) rather than a symlink
- Verify with a preflight check that resolving the path stays under the workspace before calling the tool
Example fix
// before (fails: assets is a symlink to ~/Pictures)
readMcpFile("/workspace/data/assets/photo.png")
// after (copy the real file into the workspace)
cp ~/Pictures/photo.png /workspace/data/assets/photo.png
readMcpFile("/workspace/data/assets/photo.png") Defensive patterns
Strategy: validation
Validate before calling
const resolved = fs.realpathSync.native(p);
if (!resolved.startsWith(WORKSPACE_DIR + path.sep)) {
throw new Error(`symlink escapes workspace: ${p}`);
} Type guard
function isInsideWorkspace(p, workspace) {
const { path } = require('path');
const rel = path.relative(workspace, p);
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
} Prevention
- Avoid symlinks inside the workspace; copy data in instead of linking
- After syncing/restoring a workspace, scan for symlinks: find <workspace> -type l
- Anchor external data under data/ before exposing it to MCP tools
When it happens
Trigger: Calling any MCP file tool (read/write/copy/move/remove via resolvePath, authorizeFinalPath, or authorizeArchiveEntry) with a path that is itself a symlink pointing outside the workspace, or that sits under a directory inside the workspace which is a symlink to an external location, or an archive member that extracts onto such a symlink.
Common situations: Users symlink data/ assets to an external disk or home-directory folder; a synced or restored workspace contains dangling or malicious symlinks; an uploaded zip contains entries that overwrite or traverse through existing symlinks inside the workspace.
Understand the failure class
Background: Path traversal blocked: "path escapes the workspace" and "outside site root" errors when a path will not stay inside its allowed directory — this error's family across 26 libraries.
Related errors
- access to sensitive workspace file is forbidden
- asset path resolves outside notebook assets directory
- Obsidian Vault path is unsafe
- path escapes templates dir
- resource escapes the data directory
AI-assisted analysis of siyuan-note/siyuan@9f775e8a12 (2026-09-19).
Data as JSON: /api/errors/6c2838c41a711fdd.
Report an issue: GitHub.
Appendix: source
Thrown at kernel/mcp/tools/file.go:111
if err := authorizePath(abs, rel); err != nil {
return "", err
}
return abs, nil
}
// authorizePath 校验单个最终路径是否允许访问,display 仅用于错误信息:顶层调用传工作区相对路径,
// 递归遍历、目录拷贝和压缩包解压传最终路径本身。
func authorizePath(abs, display string) error {
if !gulu.File.IsSubPath(util.WorkspaceDir, abs) {
return fmt.Errorf("path escapes workspace: %s", display)
}
// 拒绝加密笔记本目录:MCP 文件工具不能读写加密 box 下的文件(防止密文泄漏或明文破坏加密格式)
if boxID, encrypted := rejectEncryptedPath(abs); encrypted {
return fmt.Errorf("path belongs to encrypted notebook [%s]: %s", boxID, display)
}
// 防止 symlink 逃逸工作区:解析符号链接后再次检查
if resolved := util.ResolveLongestExistingParent(abs); resolved != abs && !gulu.File.IsSubPath(util.WorkspaceDir, resolved) {
return fmt.Errorf("symlink escapes workspace: %s", display)
}
// 禁止访问敏感文件(conf/conf.json、data/snippets/conf.json、data/templates、data/.siyuan/publishAccess.json),
// 与 HTTP 文件 API 共用同一黑名单(见 kernel/util/path_guard.go 的 IsForbiddenAbsPath)
if util.IsForbiddenAbsPath(abs) {
return fmt.Errorf("access to sensitive workspace file is forbidden: %s", display)
}
return nil
}
// authorizeFinalPath 对即将打开或创建的最终路径做授权。resolvePath 只覆盖调用方给出的路径,
// 容器路径合法不代表其后代合法:递归遍历、复制、解压、删除、重命名都必须对每一个后代路径再次调用本函数。
func authorizeFinalPath(abs string) error {
return authorizePath(abs, abs)
}
// authorizeSubtree 校验路径及其全部后代,任一后代被拒绝即整体拒绝。删除和重命名是目录级操作,
// 只校验目录本身会让受保护的后代被删除或搬出黑名单范围(例如 file.delete("conf"))。
// 使用 Lstat:删除和重命名不会跟随符号链接,与 os.RemoveAll、os.Rename 的语义保持一致。View on GitHub (pinned to 9f775e8a12)