@@ -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\n exit 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+ "---\n Body\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\n exit 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+ "---\n Body\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+ "---\n Body\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\n exit 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\n exit 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+ "---\n Body\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+ "---\n Body\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\n echo ran > { shlex .quote (str (marker ))} \n exit 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+ "---\n Body\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