Skip to content

Commit df0aec1

Browse files
committed
fix: confine event hook scripts to the project tree
Event dispatch joined the first scripts: token onto the .specify or extension base with Path. An absolute token discarded the base and ran a host binary. Reject anchored tokens and require the resolved path to stay inside the project root. Assisted-by: Grok (model: grok-4.6, supervised) Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
1 parent bf88c9f commit df0aec1

2 files changed

Lines changed: 212 additions & 6 deletions

File tree

src/specify_cli/events.py

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import sys
1818
import subprocess
1919
import platform
20-
from pathlib import Path
20+
from pathlib import Path, PurePosixPath, PureWindowsPath
2121
from typing import TYPE_CHECKING, Any
2222

2323
import yaml
@@ -83,7 +83,22 @@
8383
import shutil
8484
import subprocess
8585
import sys
86-
from pathlib import Path
86+
from pathlib import Path, PurePosixPath, PureWindowsPath
87+
88+
89+
def _script_under_base(base, token, project_root):
90+
"""Return token resolved under base, or None if it leaves the project."""
91+
posix_path = PurePosixPath(token)
92+
win_path = PureWindowsPath(token)
93+
if posix_path.anchor or win_path.anchor:
94+
return None
95+
try:
96+
root = project_root.resolve()
97+
candidate = (base / token).resolve()
98+
candidate.relative_to(root)
99+
except (OSError, ValueError):
100+
return None
101+
return candidate
87102
88103
89104
def _find_command_template(command_name, project_root):
@@ -228,8 +243,8 @@ def _resolve_argv(template_path, project_root, ext_id):
228243
return None
229244
if not tokens:
230245
return None
231-
script_abs = base / tokens[0]
232-
if not script_abs.exists():
246+
script_abs = _script_under_base(base, tokens[0], project_root)
247+
if script_abs is None or not script_abs.exists():
233248
return None
234249
rest = tokens[1:]
235250
@@ -541,6 +556,30 @@ def _find_command_template(command_name: str, project_root: Path) -> tuple[Path
541556
return None, None
542557

543558

559+
def _confine_event_script_path(
560+
project_root: Path, base: Path, token: str
561+
) -> Path | None:
562+
"""Resolve *token* under *base*, or None if it leaves the project.
563+
564+
Rejects anchored tokens (absolute, drive, UNC) so ``Path`` cannot
565+
discard *base*. ``..`` is allowed when the resolved path stays inside
566+
*project_root*, which is how extension templates reach core scripts
567+
via ``../../scripts/...``. Keep the generated ``_script_under_base``
568+
in sync.
569+
"""
570+
posix_path = PurePosixPath(token)
571+
win_path = PureWindowsPath(token)
572+
if posix_path.anchor or win_path.anchor:
573+
return None
574+
try:
575+
root = project_root.resolve()
576+
candidate = (base / token).resolve()
577+
candidate.relative_to(root)
578+
except (OSError, ValueError):
579+
return None
580+
return candidate
581+
582+
544583
def _resolve_event_command_argv(
545584
template_path: Path, project_root: Path, ext_id: str | None
546585
) -> list[str] | None:
@@ -609,8 +648,8 @@ def _resolve_event_command_argv(
609648
return None
610649
if not tokens:
611650
return None
612-
script_abs = base / tokens[0]
613-
if not script_abs.exists():
651+
script_abs = _confine_event_script_path(project_root, base, tokens[0])
652+
if script_abs is None or not script_abs.exists():
614653
return None
615654
rest_args = tokens[1:]
616655

tests/integrations/test_events.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1522,6 +1522,173 @@ def test_sh_variant_uses_launcher_on_windows(self, tmp_path):
15221522
else:
15231523
assert PurePath(argv[0]).as_posix().endswith(".specify/scripts/bash/boot.sh")
15241524

1525+
def test_absolute_script_token_returns_none(self, tmp_path):
1526+
"""An absolute first ``scripts:`` token must not run a host binary."""
1527+
from specify_cli.events import _resolve_event_command_argv
1528+
1529+
outside = tmp_path.parent / "outside-event-script.sh"
1530+
outside.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
1531+
cmd_dir = tmp_path / ".specify" / "templates" / "commands"
1532+
cmd_dir.mkdir(parents=True)
1533+
(cmd_dir / "boot.md").write_text(
1534+
"---\n"
1535+
"description: \"Boot\"\n"
1536+
f"scripts:\n sh: {outside.as_posix()}\n"
1537+
"---\nBody\n",
1538+
encoding="utf-8",
1539+
)
1540+
1541+
argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None)
1542+
1543+
assert argv is None
1544+
1545+
def test_dotdot_script_token_outside_project_returns_none(self, tmp_path):
1546+
"""A ``..`` walk out of the project root must not resolve."""
1547+
from specify_cli.events import _resolve_event_command_argv
1548+
1549+
outside = tmp_path.parent / "outside-event-script.sh"
1550+
outside.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
1551+
cmd_dir = tmp_path / ".specify" / "templates" / "commands"
1552+
cmd_dir.mkdir(parents=True)
1553+
(cmd_dir / "boot.md").write_text(
1554+
"---\n"
1555+
"description: \"Boot\"\n"
1556+
"scripts:\n sh: ../../outside-event-script.sh\n"
1557+
"---\nBody\n",
1558+
encoding="utf-8",
1559+
)
1560+
1561+
argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None)
1562+
1563+
assert argv is None
1564+
1565+
def test_extension_dotdot_to_core_scripts_resolves(self, tmp_path):
1566+
"""Extension templates may reach core scripts via ``../../scripts/...``."""
1567+
from specify_cli.events import _resolve_event_command_argv
1568+
1569+
ext_id = "my-ext"
1570+
cmd_dir = tmp_path / ".specify" / "extensions" / ext_id / "commands"
1571+
cmd_dir.mkdir(parents=True)
1572+
(cmd_dir / "boot.md").write_text(
1573+
"---\n"
1574+
"description: \"Boot\"\n"
1575+
"scripts:\n sh: ../../scripts/bash/helper.sh\n"
1576+
"---\nBody\n",
1577+
encoding="utf-8",
1578+
)
1579+
helper_dir = tmp_path / ".specify" / "scripts" / "bash"
1580+
helper_dir.mkdir(parents=True)
1581+
(helper_dir / "helper.sh").write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
1582+
1583+
argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, ext_id)
1584+
1585+
assert argv is not None
1586+
script_arg = argv[1] if platform.system().lower().startswith("win") else argv[0]
1587+
assert PurePath(script_arg).as_posix().endswith(".specify/scripts/bash/helper.sh")
1588+
1589+
def test_symlink_escape_returns_none(self, tmp_path):
1590+
"""A relative token that resolves through a symlink out of the project
1591+
must not run the host target."""
1592+
from specify_cli.events import _resolve_event_command_argv
1593+
1594+
host = tmp_path.parent / "host-event-script.sh"
1595+
host.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
1596+
script_dir = tmp_path / ".specify" / "scripts"
1597+
script_dir.mkdir(parents=True)
1598+
sneak = script_dir / "sneak.sh"
1599+
try:
1600+
sneak.symlink_to(host)
1601+
except OSError:
1602+
pytest.skip("symlinks are not available")
1603+
cmd_dir = tmp_path / ".specify" / "templates" / "commands"
1604+
cmd_dir.mkdir(parents=True)
1605+
(cmd_dir / "boot.md").write_text(
1606+
"---\n"
1607+
"description: \"Boot\"\n"
1608+
"scripts:\n sh: scripts/sneak.sh\n"
1609+
"---\nBody\n",
1610+
encoding="utf-8",
1611+
)
1612+
1613+
argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None)
1614+
1615+
assert argv is None
1616+
1617+
def test_windows_drive_script_token_returns_none(self, tmp_path):
1618+
"""A Windows-anchored first token must not discard the project base."""
1619+
from specify_cli.events import _resolve_event_command_argv
1620+
1621+
cmd_dir = tmp_path / ".specify" / "templates" / "commands"
1622+
cmd_dir.mkdir(parents=True)
1623+
(cmd_dir / "boot.md").write_text(
1624+
"---\n"
1625+
"description: \"Boot\"\n"
1626+
"scripts:\n sh: C:/Windows/System32/cmd.exe\n"
1627+
"---\nBody\n",
1628+
encoding="utf-8",
1629+
)
1630+
1631+
argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None)
1632+
1633+
assert argv is None
1634+
1635+
def test_dispatcher_template_confines_script_token(self):
1636+
"""The stdlib fallback dispatcher must carry the same confinement."""
1637+
from specify_cli.events import _EVENTS_DISPATCHER_TEMPLATE
1638+
1639+
assert "_script_under_base" in _EVENTS_DISPATCHER_TEMPLATE
1640+
assert "PureWindowsPath" in _EVENTS_DISPATCHER_TEMPLATE
1641+
1642+
def test_dispatcher_inline_rejects_absolute_script(self, tmp_path):
1643+
"""Inline fallback must not execute an absolute first ``scripts:`` token."""
1644+
import subprocess as _sp
1645+
import sys as _sys
1646+
1647+
if platform.system().lower().startswith("win"):
1648+
return
1649+
1650+
integration = ClaudeIntegration()
1651+
manifest = MagicMock(spec=IntegrationManifest)
1652+
manifest.files = {}
1653+
manifest.record_file = MagicMock()
1654+
manifest.record_existing = MagicMock()
1655+
install_integration_events(
1656+
integration, tmp_path, manifest,
1657+
{"session_start": [{"command": "speckit.boot"}]},
1658+
)
1659+
dispatcher = tmp_path / EVENTS_DISPATCHER_REL
1660+
marker = tmp_path / "should-not-run.out"
1661+
host = tmp_path.parent / "host-boot.sh"
1662+
host.write_text(
1663+
f"#!/bin/sh\necho ran > {shlex.quote(str(marker))}\nexit 0\n",
1664+
encoding="utf-8",
1665+
)
1666+
host.chmod(0o755)
1667+
cmd_dir = tmp_path / ".specify" / "templates" / "commands"
1668+
cmd_dir.mkdir(parents=True)
1669+
(cmd_dir / "boot.md").write_text(
1670+
"---\n"
1671+
"description: \"Boot\"\n"
1672+
f"scripts:\n sh: {host.as_posix()}\n"
1673+
"---\nBody\n",
1674+
encoding="utf-8",
1675+
)
1676+
fake_dir = tmp_path / "_fake"
1677+
(fake_dir / "specify_cli").mkdir(parents=True)
1678+
(fake_dir / "specify_cli" / "__init__.py").write_text("", encoding="utf-8")
1679+
env = dict(os.environ)
1680+
env["PYTHONPATH"] = str(fake_dir)
1681+
result = _sp.run(
1682+
[_sys.executable, str(dispatcher), "speckit.boot", "session_start", "60"],
1683+
input="{}",
1684+
capture_output=True,
1685+
text=True,
1686+
env=env,
1687+
cwd=str(tmp_path),
1688+
)
1689+
assert result.returncode == 0, result.stderr
1690+
assert not marker.exists()
1691+
15251692

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

0 commit comments

Comments
 (0)