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
16 changes: 15 additions & 1 deletion src/specify_cli/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,21 @@ def main():
# hookEventName field (required by Qwen's hooks spec; included by
# Gemini/Tabnine/Devin which derive from the same protocol).
native_event = sys.argv[5] if len(sys.argv) >= 6 else ""
payload = sys.stdin.read() if not sys.stdin.isatty() else "{}"
# Cap piped stdin at 1 MiB to prevent a DoS (mirrors the same guard on the
# `specify event run` CLI command). Read from the binary buffer so the cap
# counts encoded bytes, not decoded characters.
MAX_STDIN_BYTES = 1 * 1024 * 1024
if not sys.stdin.isatty():
raw = sys.stdin.buffer.read(MAX_STDIN_BYTES + 1)
if len(raw) > MAX_STDIN_BYTES:
print(
"stdin payload exceeds 1 MiB limit; truncate or pipe a smaller payload",
file=sys.stderr,
)
sys.exit(1)
payload = raw.decode("utf-8")
else:
payload = "{}"
project_root = Path(__file__).parent.parent.resolve()

# Preferred path: specify_cli is importable (durable install) — delegate to
Expand Down
105 changes: 105 additions & 0 deletions tests/integrations/test_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -1503,6 +1503,111 @@ def test_dispatcher_ignores_stale_specify_cli_without_confinement(self, tmp_path
)
assert not ran.exists(), f"stale package ran; stderr={result.stderr!r}"

def test_dispatcher_rejects_oversized_stdin(self, tmp_path):
"""The generated dispatcher — the actual script native hooks invoke —
must enforce the same 1 MiB stdin cap as `specify event run`. The
#3857 DoS guard previously only applied to the CLI command; the
template's own `sys.stdin.read()` had no cap at all."""
import subprocess as _sp
import sys as _sys

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

oversized = "x" * (1 * 1024 * 1024 + 10)
result = _sp.run(
[_sys.executable, str(dispatcher), "speckit.boot", "session_start", "60"],
input=oversized,
capture_output=True,
text=True,
encoding="utf-8",
cwd=str(tmp_path),
)
assert result.returncode == 1, f"stdout={result.stdout!r} stderr={result.stderr!r}"
assert "1 MiB limit" in result.stderr

def test_dispatcher_stdin_cap_counts_bytes_not_characters(self, tmp_path):
"""~300k emoji is ~1.14 MiB of UTF-8 but only 300k *characters* —
comfortably under a text-mode `sys.stdin.read(N)` character cap. The
dispatcher must still reject it by reading from the binary buffer."""
import subprocess as _sp
import sys as _sys

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

oversized = "\U0001F600" * 300_000 # 4 bytes each in UTF-8
assert len(oversized) < 1 * 1024 * 1024 # under a character-based cap
result = _sp.run(
[_sys.executable, str(dispatcher), "speckit.boot", "session_start", "60"],
input=oversized,
capture_output=True,
text=True,
encoding="utf-8",
cwd=str(tmp_path),
)
assert result.returncode == 1, f"stdout={result.stdout!r} stderr={result.stderr!r}"
assert "1 MiB limit" in result.stderr

def test_dispatcher_underlimit_stdin_still_runs(self, tmp_path):
"""A normal, under-the-cap piped payload must still reach the handler
(regression guard against an over-eager cap check)."""
import subprocess as _sp
import sys as _sys

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

cmd_dir = tmp_path / ".specify" / "templates" / "commands"
cmd_dir.mkdir(parents=True)
out_file = tmp_path / "payload.out"
(cmd_dir / "boot.md").write_text(
"---\ndescription: \"Boot\"\nscripts:\n sh: scripts/boot.sh\n---\nBody\n",
encoding="utf-8",
)
script_dir = tmp_path / ".specify" / "scripts"
script_dir.mkdir(parents=True)
script = script_dir / "boot.sh"
script.write_text(f"#!/bin/sh\ncat > {shlex.quote(str(out_file))}\nexit 0\n", encoding="utf-8")
script.chmod(0o755)

if platform.system().lower().startswith("win"):
return # sh is POSIX

result = _sp.run(
[_sys.executable, str(dispatcher), "speckit.boot", "session_start", "60"],
input='{"key": "value"}',
capture_output=True,
text=True,
cwd=str(tmp_path),
)
assert result.returncode == 0, f"stdout={result.stdout!r} stderr={result.stderr!r}"
assert out_file.read_text() == '{"key": "value"}'

def test_dispatcher_threads_per_handler_timeout(self, tmp_path):
"""S4: the generated dispatcher reads an optional 4th timeout arg and
uses it for the inner subprocess, instead of a fixed 120s cap that
Expand Down