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

  1. Replace the outside symlink with a real copy of the file inside the skill directory
  2. Keep all skill resources physically under the skill's root
  3. Audit third-party skills for symlinks before enabling them
  4. 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

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


AI-assisted analysis of siyuan-note/siyuan@8641553a1f (2026-09-11). Data as JSON: /api/errors/1be3ad32fbbaa390. Report an issue: GitHub.