github/spec-kit · error · ValueError

Integration destination {dest} escapes project root {project

Error message

Integration destination {dest} escapes project root {project_root_resolved}

What it means

Raised by GenericIntegration.setup() (src/specify_cli/integrations/generic/__init__.py:124) when (project_root / commands_dir).resolve() falls outside project_root_resolved — the relative_to containment check threw and was chained. Because commands_dir is user-supplied, this guard is the critical defense against '../' or absolute values writing outside the project.

Source

Thrown at src/specify_cli/integrations/generic/__init__.py:124

        """Install commands to the user-provided commands directory."""
        commands_dir = self._resolve_commands_dir(parsed_options, opts)

        templates = self.list_command_templates()
        if not templates:
            return []

        project_root_resolved = project_root.resolve()
        if manifest.project_root != project_root_resolved:
            raise ValueError(
                f"manifest.project_root ({manifest.project_root}) does not match "
                f"project_root ({project_root_resolved})"
            )

        dest = (project_root / commands_dir).resolve()
        try:
            dest.relative_to(project_root_resolved)
        except ValueError as exc:
            raise ValueError(
                f"Integration destination {dest} escapes "
                f"project root {project_root_resolved}"
            ) from exc
        dest.mkdir(parents=True, exist_ok=True)

        script_type = opts.get("script_type", "sh")
        arg_placeholder = "$ARGUMENTS"
        created: list[Path] = []

        for src_file in templates:
            raw = src_file.read_text(encoding="utf-8")
            processed = self.process_template(
                raw, self.key, script_type, arg_placeholder,
                project_root=project_root,
            )
            dst_name = self.command_filename(src_file.stem)
            dst_file = self.write_file_and_record(
                processed, dest / dst_name, project_root, manifest

View on GitHub (pinned to bf88c9f9a8)

Solutions

  1. Use a relative commands_dir without '..' that stays inside the project (e.g. '.myagent/commands').
  2. Remove destination symlinks that resolve outside the project.
  3. If a global install is desired, run specify per-project instead of pointing commands_dir outside.

Example fix

# before
specify init --integration generic --integration-options="--commands-dir /usr/share/agent"

# after
specify init --integration generic --integration-options="--commands-dir .agent/commands"
Defensive patterns

Strategy: validation

Validate before calling

from pathlib import Path

root = project_root.resolve()
commands_dir = opts["commands_dir"]
dest = (root / commands_dir).resolve()
try:
    dest.relative_to(root)
except ValueError:
    raise SystemExit(f"commands_dir {commands_dir!r} escapes project root")

Type guard

def is_in_project(root: Path, rel: str) -> bool:
    try:
        (root / rel).resolve().relative_to(root.resolve())
        return True
    except ValueError:
        return False

Try / catch

try:
    integration.setup(root, manifest, parsed_options=opts)
except ValueError as e:
    if "escapes project root" in str(e):
        opts["commands_dir"] = ".agent/commands"  # safe default, retry once
        integration.setup(root, manifest, parsed_options=opts)
    else:
        raise

Prevention

When it happens

Trigger: Passing parsed_options={"commands_dir": "../outside"} or an absolute path like "/etc/agent"; or a commands_dir whose first component is a symlink pointing outside the project, so resolve() escapes the root.

Common situations: Users trying to install commands into a shared/global directory; dotfile-symlinked directories (e.g. .myagent -> ~/dotfiles/.myagent); malformed --commands-dir values with leading slashes or '..'.

Related errors


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