github/spec-kit · error · ValueError
Output path {candidate!r} escapes directory {base!r}
Error message
Output path {candidate!r} escapes directory {base!r} What it means
CommandRegistrar._ensure_inside() is the path-containment backstop: it lexically normalizes both candidate and base (os.path.normpath) and raises ValueError unless the candidate is relative to the base directory. It exists to stop generated command/prompt files (or extension-provided names) from writing outside the target commands directory via traversal segments like '..'.
Source
Thrown at src/specify_cli/agents.py:578
@staticmethod
def _ensure_inside(candidate: Path, base: Path) -> None:
"""Validate that a write target stays within the expected base directory.
Uses lexical normalization so traversal via ``..`` or absolute paths is
rejected while intentionally symlinked sub-directories remain
supported.
Args:
candidate: Path that will be written.
base: Directory the write must remain within.
Raises:
ValueError: If the normalized candidate path escapes ``base``.
"""
normalized = Path(os.path.normpath(candidate))
base_normalized = Path(os.path.normpath(base))
if not normalized.is_relative_to(base_normalized):
raise ValueError(f"Output path {candidate!r} escapes directory {base!r}")
@staticmethod
def _is_safe_command_name(name: str) -> bool:
"""Reject names that could escape the commands directory via path traversal."""
if os.path.sep in name or "/" in name or "\\" in name:
return False
return os.path.normpath(name) == name
@staticmethod
def _same_lexical_path(left: Path, right: Path) -> bool:
"""Compare paths after lexical normalization without resolving symlinks."""
return os.path.normcase(os.path.normpath(os.fspath(left))) == os.path.normcase(
os.path.normpath(os.fspath(right))
)
@staticmethod
def _active_skills_agent(project_root: Path) -> Optional[str]:
"""Return the initialized skills-backed agent, if skills mode is active."""View on GitHub (pinned to bf88c9f9a8)
Solutions
- Inspect the command/prompt name being registered and remove any '..', leading '/', or absolute-path components.
- Validate extension-provided names before install using the same rule (os.path.normpath(candidate).is_relative_to(base)).
- If you maintain the extension, keep names to lowercase kebab-case identifiers with no path separators.
Example fix
# before
registrar.register(project, agent, commands=[{"name": "../../evil", "file": "x.md"}])
# after
registrar.register(project, agent, commands=[{"name": "my-cmd", "file": "x.md"}]) Defensive patterns
Strategy: validation
Validate before calling
import os
from pathlib import Path
def path_stays_inside(candidate: str, base: str) -> bool:
return Path(os.path.normpath(candidate)).is_relative_to(Path(os.path.normpath(base)))
# reject before calling the registrar
assert path_stays_inside(str(commands_dir / f"{name}.md"), str(commands_dir)) Type guard
import os
from pathlib import Path
def is_safe_output_path(candidate: os.PathLike | str, base: os.PathLike | str) -> bool:
"""True when the normalized candidate stays lexically within base."""
return Path(os.path.normpath(candidate)).is_relative_to(Path(os.path.normpath(base))) Try / catch
try:
registrar.register_commands(...)
except ValueError as exc:
if "escapes directory" in str(exc):
raise SystemExit(f"unsafe output path rejected: {exc}") from exc
raise Prevention
- Keep command/prompt names free of '..', separators, and absolute prefixes.
- Validate extension-provided names against the base directory before install.
- Never build output paths by concatenating untrusted strings; join and then containment-check.
When it happens
Trigger: A command name or resolved output path containing '..' (e.g. name '../../../etc/cron.d/x'), an absolute path, or a symlink-style traversal that survives normpath; write_copilot_prompt calls _ensure_inside(prompt_file, prompts_dir) after building prompts_dir / f"{cmd_name}.prompt.md", so a traversal-laden cmd_name trips it.
Common situations: Malicious or buggy extension manifests declaring command names with '../' segments; user-edited configuration injecting traversal; names built by string concatenation that accidentally include '..' or a leading slash. Note normpath does not resolve symlinks — a symlinked base could still be a gap, but the lexical check catches the common cases.
Related errors
- Invalid command name {cmd_name!r}: {name_reason}
- Invalid command alias {alias!r}: {alias_reason}
- Invalid Copilot prompt name {cmd_name!r}: {name_reason}
- Invalid command 'file' {label}: {reason}
- Invalid {singular} 'file' {label}: {reason}
AI-assisted analysis of github/spec-kit@bf88c9f9a8 (2026-08-14).
Data as JSON: /api/errors/f19e91e2481ce466.
Report an issue: GitHub.