Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 45 additions & 6 deletions src/specify_cli/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import sys
import subprocess
import platform
from pathlib import Path
from pathlib import Path, PurePosixPath, PureWindowsPath
from typing import TYPE_CHECKING, Any

import yaml
Expand Down Expand Up @@ -83,7 +83,22 @@
import shutil
import subprocess
import sys
from pathlib import Path
from pathlib import Path, PurePosixPath, PureWindowsPath


def _script_under_base(base, token, project_root):
"""Return token resolved under base, or None if it leaves the project."""
posix_path = PurePosixPath(token)
win_path = PureWindowsPath(token)
if posix_path.anchor or win_path.anchor:
return None
try:
root = project_root.resolve()
candidate = (base / token).resolve()
candidate.relative_to(root)
except (OSError, ValueError):
return None
return candidate


def _find_command_template(command_name, project_root):
Expand Down Expand Up @@ -228,8 +243,8 @@ def _resolve_argv(template_path, project_root, ext_id):
return None
if not tokens:
return None
script_abs = base / tokens[0]
if not script_abs.exists():
script_abs = _script_under_base(base, tokens[0], project_root)
if script_abs is None or not script_abs.exists():
return None
rest = tokens[1:]

Expand Down Expand Up @@ -541,6 +556,30 @@ def _find_command_template(command_name: str, project_root: Path) -> tuple[Path
return None, None


def _confine_event_script_path(
project_root: Path, base: Path, token: str
) -> Path | None:
"""Resolve *token* under *base*, or None if it leaves the project.

Rejects anchored tokens (absolute, drive, UNC) so ``Path`` cannot
discard *base*. ``..`` is allowed when the resolved path stays inside
*project_root*, which is how extension templates reach core scripts
via ``../../scripts/...``. Keep the generated ``_script_under_base``
in sync.
"""
posix_path = PurePosixPath(token)
win_path = PureWindowsPath(token)
if posix_path.anchor or win_path.anchor:
return None
try:
root = project_root.resolve()
candidate = (base / token).resolve()
candidate.relative_to(root)
except (OSError, ValueError):
return None
return candidate


def _resolve_event_command_argv(
template_path: Path, project_root: Path, ext_id: str | None
) -> list[str] | None:
Expand Down Expand Up @@ -609,8 +648,8 @@ def _resolve_event_command_argv(
return None
if not tokens:
return None
script_abs = base / tokens[0]
if not script_abs.exists():
script_abs = _confine_event_script_path(project_root, base, tokens[0])
if script_abs is None or not script_abs.exists():
return None
rest_args = tokens[1:]

Expand Down
167 changes: 167 additions & 0 deletions tests/integrations/test_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -1522,6 +1522,173 @@ def test_sh_variant_uses_launcher_on_windows(self, tmp_path):
else:
assert PurePath(argv[0]).as_posix().endswith(".specify/scripts/bash/boot.sh")

def test_absolute_script_token_returns_none(self, tmp_path):
"""An absolute first ``scripts:`` token must not run a host binary."""
from specify_cli.events import _resolve_event_command_argv

outside = tmp_path.parent / "outside-event-script.sh"
outside.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
cmd_dir = tmp_path / ".specify" / "templates" / "commands"
cmd_dir.mkdir(parents=True)
(cmd_dir / "boot.md").write_text(
"---\n"
"description: \"Boot\"\n"
f"scripts:\n sh: {outside.as_posix()}\n"
"---\nBody\n",
encoding="utf-8",
)

argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None)

assert argv is None

def test_dotdot_script_token_outside_project_returns_none(self, tmp_path):
"""A ``..`` walk out of the project root must not resolve."""
from specify_cli.events import _resolve_event_command_argv

outside = tmp_path.parent / "outside-event-script.sh"
outside.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
cmd_dir = tmp_path / ".specify" / "templates" / "commands"
cmd_dir.mkdir(parents=True)
(cmd_dir / "boot.md").write_text(
"---\n"
"description: \"Boot\"\n"
"scripts:\n sh: ../../outside-event-script.sh\n"
"---\nBody\n",
encoding="utf-8",
)

argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None)

assert argv is None

def test_extension_dotdot_to_core_scripts_resolves(self, tmp_path):
"""Extension templates may reach core scripts via ``../../scripts/...``."""
from specify_cli.events import _resolve_event_command_argv

ext_id = "my-ext"
cmd_dir = tmp_path / ".specify" / "extensions" / ext_id / "commands"
cmd_dir.mkdir(parents=True)
(cmd_dir / "boot.md").write_text(
"---\n"
"description: \"Boot\"\n"
"scripts:\n sh: ../../scripts/bash/helper.sh\n"
"---\nBody\n",
encoding="utf-8",
)
helper_dir = tmp_path / ".specify" / "scripts" / "bash"
helper_dir.mkdir(parents=True)
(helper_dir / "helper.sh").write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")

argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, ext_id)

assert argv is not None
script_arg = argv[1] if platform.system().lower().startswith("win") else argv[0]
assert PurePath(script_arg).as_posix().endswith(".specify/scripts/bash/helper.sh")

def test_symlink_escape_returns_none(self, tmp_path):
"""A relative token that resolves through a symlink out of the project
must not run the host target."""
from specify_cli.events import _resolve_event_command_argv

host = tmp_path.parent / "host-event-script.sh"
host.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
script_dir = tmp_path / ".specify" / "scripts"
script_dir.mkdir(parents=True)
sneak = script_dir / "sneak.sh"
try:
sneak.symlink_to(host)
except OSError:
pytest.skip("symlinks are not available")
cmd_dir = tmp_path / ".specify" / "templates" / "commands"
cmd_dir.mkdir(parents=True)
(cmd_dir / "boot.md").write_text(
"---\n"
"description: \"Boot\"\n"
"scripts:\n sh: scripts/sneak.sh\n"
"---\nBody\n",
encoding="utf-8",
)

argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None)

assert argv is None

def test_windows_drive_script_token_returns_none(self, tmp_path):
"""A Windows-anchored first token must not discard the project base."""
from specify_cli.events import _resolve_event_command_argv

cmd_dir = tmp_path / ".specify" / "templates" / "commands"
cmd_dir.mkdir(parents=True)
(cmd_dir / "boot.md").write_text(
"---\n"
"description: \"Boot\"\n"
"scripts:\n sh: C:/Windows/System32/cmd.exe\n"
"---\nBody\n",
encoding="utf-8",
)

argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None)

assert argv is None

def test_dispatcher_template_confines_script_token(self):
"""The stdlib fallback dispatcher must carry the same confinement."""
from specify_cli.events import _EVENTS_DISPATCHER_TEMPLATE

assert "_script_under_base" in _EVENTS_DISPATCHER_TEMPLATE
assert "PureWindowsPath" in _EVENTS_DISPATCHER_TEMPLATE

def test_dispatcher_inline_rejects_absolute_script(self, tmp_path):
"""Inline fallback must not execute an absolute first ``scripts:`` token."""
import subprocess as _sp
import sys as _sys

if platform.system().lower().startswith("win"):
return

integration = ClaudeIntegration()
manifest = MagicMock(spec=IntegrationManifest)
manifest.files = {}
manifest.record_file = MagicMock()
manifest.record_existing = MagicMock()
install_integration_events(
integration, tmp_path, manifest,
{"session_start": [{"command": "speckit.boot"}]},
)
dispatcher = tmp_path / EVENTS_DISPATCHER_REL
marker = tmp_path / "should-not-run.out"
host = tmp_path.parent / "host-boot.sh"
host.write_text(
f"#!/bin/sh\necho ran > {shlex.quote(str(marker))}\nexit 0\n",
encoding="utf-8",
)
host.chmod(0o755)
cmd_dir = tmp_path / ".specify" / "templates" / "commands"
cmd_dir.mkdir(parents=True)
(cmd_dir / "boot.md").write_text(
"---\n"
"description: \"Boot\"\n"
f"scripts:\n sh: {host.as_posix()}\n"
"---\nBody\n",
encoding="utf-8",
)
fake_dir = tmp_path / "_fake"
(fake_dir / "specify_cli").mkdir(parents=True)
(fake_dir / "specify_cli" / "__init__.py").write_text("", encoding="utf-8")
env = dict(os.environ)
env["PYTHONPATH"] = str(fake_dir)
result = _sp.run(
[_sys.executable, str(dispatcher), "speckit.boot", "session_start", "60"],
input="{}",
capture_output=True,
text=True,
env=env,
cwd=str(tmp_path),
)
assert result.returncode == 0, result.stderr
assert not marker.exists()


# -- Merge/teardown idempotency & safety (Tier 3) ----------------------------

Expand Down