siyuan-note/siyuan · error
skill resource escapes skill directory: %s
Error message
skill resource escapes skill directory: %s
What it means
readSkillResource refuses to serve any resource whose symlink-resolved real path is outside the skill directory (realTarget != realRoot and not IsSubPath). This is a deliberate security boundary preventing skills from exposing arbitrary files via symlink or '..'-style tricks that survived normalization.
Source
Thrown at kernel/util/skill.go:367
truncated = true
}
sort.Strings(resources)
return resources, truncated
}
func readSkillResource(skillDir, skillName, resource string) (string, error) {
realRoot, err := filepath.EvalSymlinks(skillDir)
if err != nil {
return "", fmt.Errorf("skill not found: %s", skillName)
}
target := filepath.Join(realRoot, filepath.FromSlash(resource))
realTarget, err := filepath.EvalSymlinks(target)
if err != nil {
return "", fmt.Errorf("skill resource not found: %s/%s", skillName, resource)
}
if realTarget != realRoot && !gulu.File.IsSubPath(realRoot, realTarget) {
return "", fmt.Errorf("skill resource escapes skill directory: %s", resource)
}
info, err := os.Stat(realTarget)
if err != nil || !info.Mode().IsRegular() {
return "", fmt.Errorf("skill resource is not a regular file: %s/%s", skillName, resource)
}
if info.Size() > maxSkillResourceBytes {
return "", fmt.Errorf("skill resource exceeds the %d byte limit: %s/%s", maxSkillResourceBytes, skillName, resource)
}
file, err := os.Open(realTarget)
if err != nil {
return "", fmt.Errorf("skill resource not found: %s/%s", skillName, resource)
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, maxSkillResourceBytes+1))
if err != nil {
return "", fmt.Errorf("skill resource read failed: %s/%s", skillName, resource)View on GitHub (pinned to 8641553a1f)
Solutions
- Replace the outside symlink with a real copy of the file inside the skill directory
- Keep all skill resources physically under the skill's root
- Audit third-party skills for symlinks before enabling them
- If shared content is needed, duplicate it or publish it as a separate enabled skill
Example fix
// before ln -s /home/user/secrets.md ~/.siyuan/skills/my-skill/notes.md // after cp /home/user/docs/shared-notes.md ~/.siyuan/skills/my-skill/notes.md
Defensive patterns
Strategy: validation
Validate before calling
const fs = require("fs");
function staysInsideSkill(skillDir, res) {
const real = fs.realpathSync(p.join(skillDir, res));
const root = fs.realpathSync(skillDir);
return real === root || real.startsWith(root + p.sep);
} Prevention
- Do not create symlinks to files outside the skill directory
- Copy shared content into the skill instead of linking it
- Audit third-party skills for symlinks before enabling
When it happens
Trigger: A symlink (or chain of symlinks) inside the skill directory points to a file outside it; a resource path that resolves, after EvalSymlinks, to a parent-of-root location.
Common situations: User-created skills containing convenience symlinks to shared docs elsewhere on disk; skills copied from untrusted sources with escape links; linking skill assets to files in another 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
- symlink escapes workspace: %s
- notebook asset path resolves outside notebook directory: %s
- asset path resolves outside assets directory: %s
- [%s] is not sub path of workspace
- symlink [%s] resolves outside workspace: [%s]
AI-assisted analysis of siyuan-note/siyuan@8641553a1f (2026-09-11).
Data as JSON: /api/errors/1be3ad32fbbaa390.
Report an issue: GitHub.