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

  1. 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
  2. Check where the link points (ls -l / readlink) and confirm whether the target should be inside the workspace; re-anchor it there
  3. If the link is intentional, expose the target through a supported mechanism (e.g. copy into data/) rather than a symlink
  4. 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

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


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)