Skip to content

Commit 4c3361b

Browse files
committed
fix: refuse stale specify_cli.events without path confinement
Generated dispatchers only delegate when EVENT_SCRIPT_PATH_CONFINEMENT is True, so an older global install cannot bypass the project-tree guard. Assisted-by: Grok (xAI, under direct human supervision) Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
1 parent df0aec1 commit 4c3361b

2 files changed

Lines changed: 64 additions & 3 deletions

File tree

‎src/specify_cli/events.py‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@
3030

3131
# -- Constants -------------------------------------------------------------
3232

33+
# Generated hook dispatchers refuse to delegate unless this name is True.
34+
# An older installed specify_cli.events (uvx-init plus a stale global
35+
# install) would otherwise run unconfined script tokens.
36+
EVENT_SCRIPT_PATH_CONFINEMENT = True
37+
3338
EVENTS_DISPATCHER_DIR = Path(".specify")
3439
EVENTS_DISPATCHER_FILENAME = "events.py"
3540
# POSIX-form (forward-slash) relative path so it matches manifest keys, which
@@ -376,8 +381,15 @@ def main():
376381
# Preferred path: specify_cli is importable (durable install) — delegate to
377382
# the full resolver, which also handles extension manifests whose file stem
378383
# differs from the command name and the project's custom script selection.
384+
# Require EVENT_SCRIPT_PATH_CONFINEMENT so a stale global install cannot
385+
# bypass the generated dispatcher's path guard.
379386
try:
380-
from specify_cli.events import resolve_and_run_event_command
387+
from specify_cli.events import (
388+
EVENT_SCRIPT_PATH_CONFINEMENT as _confine_ok,
389+
resolve_and_run_event_command,
390+
)
391+
if _confine_ok is not True:
392+
raise ImportError("specify_cli.events lacks script path confinement")
381393
sys.exit(
382394
resolve_and_run_event_command(
383395
command_name, _event_name, payload, project_root, timeout=timeout, envelope=envelope, native_event=native_event

‎tests/integrations/test_events.py‎

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1388,8 +1388,10 @@ def test_dispatcher_is_self_contained(self, tmp_path):
13881388
{"pre_tool_use": [{"command": "speckit.tdd.validate"}]},
13891389
)
13901390
content = (tmp_path / EVENTS_DISPATCHER_REL).read_text()
1391-
# Delegates to specify_cli when importable.
1392-
assert "from specify_cli.events import resolve_and_run_event_command" in content
1391+
# Delegates to specify_cli when importable and confinement is present.
1392+
assert "EVENT_SCRIPT_PATH_CONFINEMENT" in content
1393+
assert "from specify_cli.events import" in content
1394+
assert "resolve_and_run_event_command" in content
13931395
assert "except (ImportError, TypeError):" in content
13941396
# Inline stdlib fallback resolver for one-time/temporary installs.
13951397
assert "_run_inline" in content
@@ -1454,6 +1456,53 @@ def test_dispatcher_inline_fallback_runs_script(self, tmp_path):
14541456
assert out_file.exists(), f"inline fallback did not run script; stderr={result.stderr!r} rc={result.returncode}"
14551457
assert out_file.read_text() == '{"tool_name":"x"}'
14561458

1459+
def test_dispatcher_ignores_stale_specify_cli_without_confinement(self, tmp_path):
1460+
"""A generated dispatcher must not delegate to an older specify_cli
1461+
that lacks EVENT_SCRIPT_PATH_CONFINEMENT (uvx-init plus stale
1462+
global install). Absolute script tokens stay rejected."""
1463+
import subprocess as _sp
1464+
import sys as _sys
1465+
1466+
integration = ClaudeIntegration()
1467+
manifest = MagicMock(spec=IntegrationManifest)
1468+
manifest.files = {}
1469+
manifest.record_file = MagicMock()
1470+
manifest.record_existing = MagicMock()
1471+
install_integration_events(
1472+
integration, tmp_path, manifest,
1473+
{"session_start": [{"command": "speckit.boot"}]},
1474+
)
1475+
dispatcher = tmp_path / EVENTS_DISPATCHER_REL
1476+
cmd_dir = tmp_path / ".specify" / "templates" / "commands"
1477+
cmd_dir.mkdir(parents=True)
1478+
ran = tmp_path / "stale-ran"
1479+
(cmd_dir / "boot.md").write_text(
1480+
"---\ndescription: \"Boot\"\nscripts:\n sh: /tmp/outside.sh\n---\nBody\n",
1481+
encoding="utf-8",
1482+
)
1483+
1484+
fake_dir = tmp_path / "_stale_pkg"
1485+
pkg = fake_dir / "specify_cli"
1486+
pkg.mkdir(parents=True)
1487+
(pkg / "__init__.py").write_text("", encoding="utf-8")
1488+
(pkg / "events.py").write_text(
1489+
"def resolve_and_run_event_command(*_a, **_k):\n"
1490+
f" open({str(ran)!r}, 'w').write('delegated')\n"
1491+
" return 0\n",
1492+
encoding="utf-8",
1493+
)
1494+
env = dict(os.environ)
1495+
env["PYTHONPATH"] = str(fake_dir)
1496+
result = _sp.run(
1497+
[_sys.executable, str(dispatcher), "speckit.boot", "session_start", "60"],
1498+
input="{}",
1499+
capture_output=True,
1500+
text=True,
1501+
env=env,
1502+
cwd=str(tmp_path),
1503+
)
1504+
assert not ran.exists(), f"stale package ran; stderr={result.stderr!r}"
1505+
14571506
def test_dispatcher_threads_per_handler_timeout(self, tmp_path):
14581507
"""S4: the generated dispatcher reads an optional 4th timeout arg and
14591508
uses it for the inner subprocess, instead of a fixed 120s cap that

0 commit comments

Comments
 (0)