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

  1. Inspect the command/prompt name being registered and remove any '..', leading '/', or absolute-path components.
  2. Validate extension-provided names before install using the same rule (os.path.normpath(candidate).is_relative_to(base)).
  3. 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

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


AI-assisted analysis of github/spec-kit@bf88c9f9a8 (2026-08-14). Data as JSON: /api/errors/f19e91e2481ce466. Report an issue: GitHub.