bmad-code-org/BMAD-METHOD · critical · RenderError
render source escapes skill directory: {name}
Error message
render source escapes skill directory: {name} What it means
Security guard in _load_sources: after resolving each *.md candidate (following symlinks via Path.resolve(strict=True)), the renderer verifies the real path stays inside skill_dir. A symlink that resolves outside the skill directory is rejected to prevent path traversal and arbitrary external content being baked into the immutable snapshot.
Source
Thrown at src/scripts/render_skill.py:106
"instruction": _require_string(
item.get("instruction"), f"{item_label}.instruction", allow_empty=True
),
}
if "when" in item:
layer["when"] = _require_string(item["when"], f"{item_label}.when")
result.append(layer)
return result
def _load_sources(skill_dir: Path) -> dict[str, str]:
sources: dict[str, str] = {}
for candidate in sorted(skill_dir.rglob("*.md")):
if candidate.name == "SKILL.md":
continue
name = candidate.relative_to(skill_dir).as_posix()
path = candidate.resolve(strict=True)
if not path.is_relative_to(skill_dir):
raise RenderError(f"render source escapes skill directory: {name}")
if not path.is_file():
raise RenderError(f"render source is missing or not a file: {path}")
try:
sources[name] = path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as error:
raise RenderError(f"failed to read render source {path}: {error}") from error
if "workflow.md" not in sources:
raise RenderError(f"render entry is missing: {skill_dir / 'workflow.md'}")
return sources
def _resolve_config_value(value: Any, label: str, project_root: Path) -> str:
text = _require_string(value, label)
if "{project-root}" not in text:
return text
resolved = text.replace("{project-root}", str(project_root))
if not Path(resolved).is_absolute():
raise RenderError(f"{label} must resolve to an absolute path: {resolved}")View on GitHub (pinned to b70486b9bd)
Solutions
- Replace the offending symlink with a real file (copy) inside the skill directory.
- Repoint the symlink so its target resolves to a path inside the skill directory.
- Avoid symlinks for skill sources; keep all .md files physical within the skill dir.
Example fix
# before ln -s ../../shared/notes.md skills/my-skill/notes.md # after cp ../../shared/notes.md skills/my-skill/notes.md # or keep the symlink target inside the skill directory
Defensive patterns
Strategy: validation
Validate before calling
from pathlib import Path
def assert_sources_inside(skill_dir: Path) -> None:
skill_dir = skill_dir.resolve()
for cand in skill_dir.rglob("*.md"):
if cand.name == "SKILL.md":
continue
real = cand.resolve(strict=True)
if not real.is_relative_to(skill_dir):
raise SystemExit(f"source escapes skill dir: {cand}") Prevention
- Avoid symlinks for skill sources; keep .md files physical inside the skill dir.
- Audit symlinks with find <skill_dir> -type l -name '*.md' before rendering.
- If sharing content, copy it in rather than symlinking across package boundaries.
When it happens
Trigger: A symlinked .md file inside the skill directory whose target resolves outside skill_dir.resolve() -- e.g. ln -s /etc/passwd skills/my-skill/notes.md, or a directory symlink creating an escape. path.is_relative_to(skill_dir) returns False.
Common situations: Symlinking shared docs from another skill or repo; developer symlinks into /tmp or $HOME; monorepo cross-package symlinks; bundling skills via symlink farms.
Related errors
- render source is missing or not a file: {path}
- failed to read render source {path}: {error}
- TOML layer is not a file: {path}
- render entry is missing: {skill_dir / 'workflow.md'}
- corrupt existing generation {destination}: {error}
AI-assisted analysis of bmad-code-org/BMAD-METHOD@b70486b9bd (2026-08-13).
Data as JSON: /api/errors/5c8e1cffcd87847d.
Report an issue: GitHub.