Files
hermes-agent/tests/hermes_cli/test_prompt_compose_command.py
Brooklyn Nicholson 4b707a0f2b fix(cli): cancel /prompt compose on editor failure instead of shell fallback
_compose_in_editor fell back to shell=True with the raw $EDITOR value
unquoted when the argv invocation raised, so a crafted EDITOR value ran as
shell code. Drop the fallback: an unargv-splittable or unrunnable editor,
or a nonzero editor exit (which may leave abandoned buffer text), now
cancels the compose cleanly.

Co-authored-by: Adolanium <94890352+Adolanium@users.noreply.github.com>
2026-09-30 22:24:00 -05:00

90 lines
3.4 KiB
Python

"""Tests for the CLI `/prompt` editor-compose command.
`/prompt` opens `$VISUAL`/`$EDITOR` on a temp markdown file so the user can
hand-edit a multi-line prompt, then queues the saved buffer as the next
agent turn via the one-shot `_pending_agent_seed` (same path `/blueprint`
uses). These drive a fake editor subprocess to verify read-back, header
stripping, seeding, and the empty-buffer cancel path.
"""
import sys
from pathlib import Path
import pytest
from hermes_cli.cli_commands_mixin import CLICommandsMixin
class _Stub(CLICommandsMixin):
def __init__(self):
self._pending_agent_seed = None
def _fake_editor(tmp_path, body: str, mode: str = "append", exit_code: int = 0) -> str:
"""Write a tiny python 'editor' that mutates the file it is handed."""
script = tmp_path / "editor.py"
script.write_text(
"import sys\nfrom pathlib import Path\n"
"path = Path(sys.argv[1])\n"
+ (f"path.write_text(path.read_text(encoding='utf-8') + {body!r}, encoding='utf-8')\n"
if mode == "append" else "path.write_text('', encoding='utf-8')\n")
+ f"sys.exit({exit_code})\n", encoding="utf-8")
return f'"{Path(sys.executable).as_posix()}" "{script.as_posix()}"'
@pytest.fixture(autouse=True)
def _no_visual(monkeypatch):
monkeypatch.delenv("VISUAL", raising=False)
monkeypatch.delenv("EDITOR", raising=False)
@pytest.mark.platforms("linux")
def test_compose_reads_and_strips_header(tmp_path, monkeypatch):
monkeypatch.setenv("EDITOR", _fake_editor(tmp_path, "Refactor the auth module.\nUse pytest."))
out = _Stub()._compose_in_editor("")
assert "Refactor the auth module." in out
assert "Use pytest." in out
assert "#!" not in out # the instructional header is stripped
@pytest.mark.platforms("linux")
def test_empty_buffer_does_not_seed(tmp_path, monkeypatch):
monkeypatch.setenv("EDITOR", _fake_editor(tmp_path, "", mode="clear"))
s = _Stub()
s._handle_prompt_compose_command("/prompt")
assert s._pending_agent_seed is None
def test_editor_failure_never_falls_back_to_shell(monkeypatch):
"""An editor the argv path cannot run must not retry through the shell.
The old fallback re-invoked ``$EDITOR`` via ``shell=True`` with the editor
string unquoted, so a crafted EDITOR value executed as shell code. Now a
failed editor simply cancels the compose (#81364).
"""
import subprocess as _sp
calls = []
def _record(args, **kwargs):
calls.append((args, kwargs))
raise OSError("editor not runnable")
monkeypatch.setattr(_sp, "call", _record)
monkeypatch.setenv("EDITOR", "nonexistent-editor; touch /tmp/pwned")
assert _Stub()._compose_in_editor("") == ""
assert calls, "argv invocation should have been attempted"
for argv, kwargs in calls:
assert kwargs.get("shell") is not True
monkeypatch.setenv("EDITOR", '"unterminated')
assert _Stub()._compose_in_editor("seed") == ""
def test_nonzero_editor_exit_cancels_even_with_buffer_content(tmp_path, monkeypatch):
"""A failed editor may leave seeded or abandoned text in the buffer — cancel."""
monkeypatch.setenv("EDITOR", _fake_editor(tmp_path, "typed but abandoned\n", exit_code=3))
assert _Stub()._compose_in_editor("seed text from the command line") == ""
stub = _Stub()
stub._handle_prompt_compose_command("/prompt draft this")
assert stub._pending_agent_seed is None