{"record":{"id":"1be3ad32fbbaa390","repo":"siyuan-note/siyuan","slug":"skill-resource-escapes-skill-directory-s","errorCode":null,"errorMessage":"skill resource escapes skill directory: %s","messagePattern":"skill resource escapes skill directory: (.+?)","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"kernel/util/skill.go","lineNumber":367,"sourceCode":"\t\ttruncated = true\n\t}\n\tsort.Strings(resources)\n\treturn resources, truncated\n}\n\nfunc readSkillResource(skillDir, skillName, resource string) (string, error) {\n\trealRoot, err := filepath.EvalSymlinks(skillDir)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"skill not found: %s\", skillName)\n\t}\n\n\ttarget := filepath.Join(realRoot, filepath.FromSlash(resource))\n\trealTarget, err := filepath.EvalSymlinks(target)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"skill resource not found: %s/%s\", skillName, resource)\n\t}\n\tif realTarget != realRoot && !gulu.File.IsSubPath(realRoot, realTarget) {\n\t\treturn \"\", fmt.Errorf(\"skill resource escapes skill directory: %s\", resource)\n\t}\n\n\tinfo, err := os.Stat(realTarget)\n\tif err != nil || !info.Mode().IsRegular() {\n\t\treturn \"\", fmt.Errorf(\"skill resource is not a regular file: %s/%s\", skillName, resource)\n\t}\n\tif info.Size() > maxSkillResourceBytes {\n\t\treturn \"\", fmt.Errorf(\"skill resource exceeds the %d byte limit: %s/%s\", maxSkillResourceBytes, skillName, resource)\n\t}\n\n\tfile, err := os.Open(realTarget)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"skill resource not found: %s/%s\", skillName, resource)\n\t}\n\tdefer file.Close()\n\tdata, err := io.ReadAll(io.LimitReader(file, maxSkillResourceBytes+1))\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"skill resource read failed: %s/%s\", skillName, resource)","sourceCodeStart":349,"sourceCodeEnd":385,"githubUrl":"https://github.com/siyuan-note/siyuan/blob/8641553a1f07374001902d3ce773285db1292b2d/kernel/util/skill.go#L349-L385","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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"],"exampleFix":"// before\nln -s /home/user/secrets.md ~/.siyuan/skills/my-skill/notes.md\n// after\ncp /home/user/docs/shared-notes.md ~/.siyuan/skills/my-skill/notes.md","handlingStrategy":"validation","validationCode":"const fs = require(\"fs\");\nfunction staysInsideSkill(skillDir, res) {\n  const real = fs.realpathSync(p.join(skillDir, res));\n  const root = fs.realpathSync(skillDir);\n  return real === root || real.startsWith(root + p.sep);\n}","typeGuard":null,"tryCatchPattern":null,"preventionTips":["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"],"tags":["skills","security","symlink","path-traversal"],"backgroundTag":"path-traversal-blocked","analyzedSha":"8641553a1f07374001902d3ce773285db1292b2d","analyzedAt":"2026-09-11T16:08:28.414Z","contentChangedAt":"2026-09-11T16:08:28.414Z","schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}