feat(cli): ! shell mode — run a command without spending a model turn
This commit is contained in:
94
cli.py
94
cli.py
@@ -7028,6 +7028,22 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
||||
|
||||
app = getattr(self, "_app", None)
|
||||
|
||||
# `!<command>` shell mode, checked before slash dispatch — matches the
|
||||
# Enter path in the input loop so an editor-saved bang command runs
|
||||
# locally instead of being sent to the agent.
|
||||
try:
|
||||
if self.handle_bang_shell(text):
|
||||
self._reset_input_buffer(buffer)
|
||||
if app is not None:
|
||||
app.invalidate()
|
||||
return
|
||||
except Exception as exc:
|
||||
_cprint(f" {_DIM}Shell command failed: {exc}{_RST}")
|
||||
self._reset_input_buffer(buffer)
|
||||
if app is not None:
|
||||
app.invalidate()
|
||||
return
|
||||
|
||||
# Slash commands: dispatch directly, same as the Enter handler's
|
||||
# _looks_like_slash_command branch.
|
||||
if _looks_like_slash_command(text):
|
||||
@@ -9588,6 +9604,64 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
||||
"""Print through the active command-safe console."""
|
||||
self._output_console().print(*args, **kwargs)
|
||||
|
||||
def handle_bang_shell(self, text: str) -> bool:
|
||||
"""Run a ``!<command>`` submission. Returns True when it was handled.
|
||||
|
||||
Dispatched from the input loop BEFORE slash-command routing and before
|
||||
anything is queued for the agent, so a bang command never becomes a
|
||||
turn: no user message, no assistant message, no tool result touches
|
||||
``self.conversation_history``. That is what makes ``!`` free — zero
|
||||
tokens, and role alternation / prompt caching are untouched by
|
||||
construction. The invariant is covered by
|
||||
tests/cli/test_bang_shell_mode.py.
|
||||
|
||||
Returns False when the text is not a bang command or when bang mode is
|
||||
disabled for this context (gateway/cron), letting the caller fall
|
||||
through to normal routing.
|
||||
"""
|
||||
from hermes_cli.bang_shell import (
|
||||
USAGE_HINT,
|
||||
bang_shell_enabled,
|
||||
check_bang_approval,
|
||||
is_bang_command,
|
||||
parse_bang_command,
|
||||
resolve_bang_cwd,
|
||||
run_bang_command,
|
||||
)
|
||||
|
||||
if not is_bang_command(text):
|
||||
return False
|
||||
if not bang_shell_enabled():
|
||||
# Gateway / cron / API contexts: no composer, no human at a
|
||||
# keyboard, and those users already have their own shells. Let the
|
||||
# text route normally rather than becoming remote execution.
|
||||
return False
|
||||
|
||||
command = parse_bang_command(text)
|
||||
if not command:
|
||||
# Bare `!` — show what the feature does instead of running an
|
||||
# empty shell or sending "!" to the model.
|
||||
self._console_print(f"[dim]{USAGE_HINT}[/]")
|
||||
return True
|
||||
|
||||
approval = check_bang_approval(command)
|
||||
if not approval.get("approved"):
|
||||
message = approval.get("message") or (
|
||||
f"Command denied: {approval.get('description', 'flagged as dangerous')}"
|
||||
)
|
||||
self._console_print(f"[bold red]{_escape(str(message))}[/]")
|
||||
return True
|
||||
|
||||
cwd = resolve_bang_cwd(getattr(self, "session_id", None))
|
||||
exit_code = run_bang_command(
|
||||
command,
|
||||
cwd=cwd,
|
||||
writer=lambda line: self._console_print(_rich_text_from_ansi(line)),
|
||||
)
|
||||
if exit_code:
|
||||
self._console_print(f"[dim]! exited {exit_code}[/]")
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _resolve_personality_prompt(value) -> str:
|
||||
"""Accept string or dict personality value; return system prompt string."""
|
||||
@@ -15131,7 +15205,14 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
||||
event.app.invalidate()
|
||||
# Bundle text + images as a tuple when images are present
|
||||
payload = (text, images) if images else text
|
||||
if self._agent_running and not (text and _looks_like_slash_command(text)):
|
||||
# A bang command is treated like a slash command while the
|
||||
# agent is busy: it must never be routed into steer/redirect
|
||||
# (which would inject `!git status` into the model's context as
|
||||
# a prompt). It queues and runs locally once the loop drains.
|
||||
_is_local_dispatch = bool(text) and (
|
||||
_looks_like_slash_command(text) or text.strip().startswith("!")
|
||||
)
|
||||
if self._agent_running and not _is_local_dispatch:
|
||||
_effective_mode = self.busy_input_mode
|
||||
redirected = False
|
||||
if _effective_mode == "steer":
|
||||
@@ -17132,6 +17213,17 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
||||
):
|
||||
continue
|
||||
|
||||
# `!<command>` shell mode — run it here and loop back to
|
||||
# idle. Checked BEFORE slash routing and before the chat
|
||||
# path so nothing enters conversation history and no model
|
||||
# turn is spent. See handle_bang_shell().
|
||||
if (
|
||||
not _file_drop
|
||||
and isinstance(user_input, str)
|
||||
and self.handle_bang_shell(user_input)
|
||||
):
|
||||
continue
|
||||
|
||||
if not _file_drop and isinstance(user_input, str) and _looks_like_slash_command(user_input):
|
||||
_cprint(f"\n⚙️ {user_input}")
|
||||
try:
|
||||
|
||||
212
hermes_cli/bang_shell.py
Normal file
212
hermes_cli/bang_shell.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""``!<command>`` shell mode for the interactive CLI.
|
||||
|
||||
Typing ``!git status`` at the composer runs the command directly in the
|
||||
session's working directory. The model is never invoked: no user message, no
|
||||
assistant message, no tool result enters the conversation history, so a bang
|
||||
command costs zero tokens and cannot perturb role alternation or the prompt
|
||||
cache.
|
||||
|
||||
A user-typed command still goes through the SAME dangerous-pattern approval
|
||||
gate the terminal tool uses (``tools.approval.check_all_command_guards``),
|
||||
reached here through ``tools.terminal_tool._check_all_guards`` so the CLI
|
||||
approval callback and Docker host-access handling behave identically.
|
||||
|
||||
CLI-only by design: gateway/API/cron sessions have their own shells and no
|
||||
composer, so :func:`bang_shell_enabled` gates the feature off there.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from typing import Optional
|
||||
|
||||
USAGE_HINT = "Usage: !<command> — run a shell command without spending a model turn (e.g. !git status)"
|
||||
|
||||
# Bang commands are interactive convenience, not agent work. Keep the ceiling
|
||||
# well under the terminal tool's foreground cap: a user watching output can
|
||||
# Ctrl+C, and an accidental `!sleep 999` should not wedge the composer.
|
||||
DEFAULT_TIMEOUT = 120
|
||||
|
||||
|
||||
def is_bang_command(text: Optional[str]) -> bool:
|
||||
"""Return True when *text* is a ``!`` shell-mode submission.
|
||||
|
||||
Only a leading ``!`` (after surrounding whitespace) counts. A line that
|
||||
merely *contains* ``!`` mid-text (``fix the bug!``, ``echo hi!``) is an
|
||||
ordinary prompt and must reach the agent untouched.
|
||||
"""
|
||||
if not isinstance(text, str):
|
||||
return False
|
||||
return text.strip().startswith("!")
|
||||
|
||||
|
||||
def parse_bang_command(text: str) -> str:
|
||||
"""Return the shell command inside a bang submission (``""`` when bare).
|
||||
|
||||
``!ls`` → ``ls``; ``! ls -la`` → ``ls -la``; ``!!`` → ``!`` (a literal
|
||||
second bang is part of the command, e.g. history expansion the user's
|
||||
shell will handle); ``!`` alone → ``""``.
|
||||
"""
|
||||
if not isinstance(text, str):
|
||||
return ""
|
||||
stripped = text.strip()
|
||||
if not stripped.startswith("!"):
|
||||
return ""
|
||||
return stripped[1:].strip()
|
||||
|
||||
|
||||
def bang_shell_enabled() -> bool:
|
||||
"""True only for interactive local CLI sessions.
|
||||
|
||||
Gateway, API, and cron sessions never reach the composer and their users
|
||||
already have a shell; running arbitrary commands for them would be a
|
||||
remote-execution surface with no approving human at the keyboard.
|
||||
"""
|
||||
try:
|
||||
from utils import env_var_enabled
|
||||
except Exception: # pragma: no cover - utils is always importable in-tree
|
||||
def env_var_enabled(name, default=""): # type: ignore[misc]
|
||||
return str(os.getenv(name, default)).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
if env_var_enabled("HERMES_GATEWAY_SESSION"):
|
||||
return False
|
||||
if env_var_enabled("HERMES_CRON_SESSION"):
|
||||
return False
|
||||
if (os.getenv("HERMES_SESSION_PLATFORM") or "").strip():
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def resolve_bang_cwd(session_key: Optional[str] = None) -> Optional[str]:
|
||||
"""Return the directory a bang command should run in.
|
||||
|
||||
Mirrors the terminal tool's resolution order so ``!pwd`` matches where the
|
||||
agent's own commands land: the session's recorded ``cd`` state first
|
||||
(``terminal_tool.get_session_cwd``, updated after every agent command),
|
||||
then the configured ``TERMINAL_CWD``/backend default. ``None`` means "let
|
||||
the subprocess inherit the process cwd".
|
||||
"""
|
||||
try:
|
||||
from tools.terminal_tool import _get_env_config, get_session_cwd
|
||||
|
||||
recorded = get_session_cwd(session_key)
|
||||
if recorded:
|
||||
return recorded
|
||||
configured = (_get_env_config() or {}).get("cwd")
|
||||
if configured:
|
||||
return configured
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def check_bang_approval(command: str) -> dict:
|
||||
"""Run *command* through the terminal tool's approval gate.
|
||||
|
||||
Reuses ``tools.terminal_tool._check_all_guards`` — the exact function
|
||||
``terminal_tool()`` calls before executing anything — so the hardline
|
||||
blocklist, user deny rules, tirith findings, and the interactive
|
||||
dangerous-command prompt all apply to user-typed bang commands too. A
|
||||
command the agent would need approval for still needs approval when the
|
||||
user types it; ``!`` is a latency/cost shortcut, not a security bypass.
|
||||
|
||||
Returns the gate's decision dict (``{"approved": bool, "message": ...}``).
|
||||
Falls back to *approved* only when the gate itself cannot be imported,
|
||||
which would mean a broken install rather than a policy decision.
|
||||
"""
|
||||
try:
|
||||
from tools.terminal_tool import _check_all_guards
|
||||
except Exception:
|
||||
return {"approved": True, "message": None}
|
||||
|
||||
# env_type mirrors the terminal tool: bang commands always run locally in
|
||||
# the CLI process, never inside a remote/sandbox backend.
|
||||
return _check_all_guards(command, "local", has_host_access=False)
|
||||
|
||||
|
||||
def _bang_env() -> dict:
|
||||
"""Environment for a bang command, with Hermes-managed secrets filtered.
|
||||
|
||||
The CLI process holds every configured provider API key in ``os.environ``.
|
||||
A bang command is user-typed, but it can still be a third-party script, so
|
||||
reuse the same sanitizer ``quick_commands`` and the local terminal backend
|
||||
use rather than handing the whole keyring to an arbitrary subprocess.
|
||||
"""
|
||||
try:
|
||||
from tools.environments.local import _sanitize_subprocess_env
|
||||
|
||||
return _sanitize_subprocess_env(os.environ.copy())
|
||||
except Exception:
|
||||
return os.environ.copy()
|
||||
|
||||
|
||||
def run_bang_command(
|
||||
command: str,
|
||||
*,
|
||||
cwd: Optional[str] = None,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
writer=None,
|
||||
) -> int:
|
||||
"""Execute *command* and stream its output, returning the exit code.
|
||||
|
||||
stdout and stderr are merged and written through *writer* (defaults to
|
||||
``print``) as they arrive, so long-running commands show progress instead
|
||||
of buffering to the end. Nothing is returned to a caller for insertion
|
||||
into conversation history — the output exists only on the user's terminal.
|
||||
"""
|
||||
emit = writer or (lambda line: print(line, end="" if line.endswith("\n") else "\n"))
|
||||
|
||||
run_cwd = cwd if (cwd and os.path.isdir(os.path.expanduser(cwd))) else None
|
||||
if run_cwd:
|
||||
run_cwd = os.path.expanduser(run_cwd)
|
||||
|
||||
try:
|
||||
from hermes_cli._subprocess_compat import windows_hide_flags
|
||||
|
||||
creationflags = windows_hide_flags()
|
||||
except Exception:
|
||||
creationflags = 0
|
||||
|
||||
try:
|
||||
# shell=True is intentional and matches quick_commands: this is a
|
||||
# command the human typed into their own composer, not model output.
|
||||
proc = subprocess.Popen(
|
||||
command,
|
||||
shell=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
cwd=run_cwd,
|
||||
env=_bang_env(),
|
||||
creationflags=creationflags,
|
||||
)
|
||||
except Exception as exc:
|
||||
emit(f"!: failed to run command: {exc}")
|
||||
return 127
|
||||
|
||||
try:
|
||||
if proc.stdout is not None:
|
||||
for line in proc.stdout:
|
||||
emit(line.rstrip("\n"))
|
||||
proc.wait(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
emit(f"!: command timed out after {timeout}s")
|
||||
return 124
|
||||
except KeyboardInterrupt:
|
||||
# Ctrl+C interrupts the command, not the Hermes session.
|
||||
proc.kill()
|
||||
emit("!: interrupted")
|
||||
return 130
|
||||
finally:
|
||||
try:
|
||||
if proc.stdout is not None:
|
||||
proc.stdout.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return int(proc.returncode or 0)
|
||||
|
||||
313
tests/cli/test_bang_shell_mode.py
Normal file
313
tests/cli/test_bang_shell_mode.py
Normal file
@@ -0,0 +1,313 @@
|
||||
"""Tests for `!<command>` shell mode in the interactive CLI.
|
||||
|
||||
Covers bang detection/parsing, that the terminal tool's approval gate is
|
||||
invoked for a dangerous command, that non-zero exit codes surface, and the
|
||||
load-bearing invariant: a bang command leaves conversation_history
|
||||
byte-identical because it never becomes a turn.
|
||||
"""
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.bang_shell import (
|
||||
USAGE_HINT,
|
||||
bang_shell_enabled,
|
||||
is_bang_command,
|
||||
parse_bang_command,
|
||||
run_bang_command,
|
||||
)
|
||||
|
||||
|
||||
# ── detection / parsing ────────────────────────────────────────────────────
|
||||
|
||||
class TestBangDetection:
|
||||
@pytest.mark.parametrize("text", [
|
||||
"!ls",
|
||||
"!git status",
|
||||
" !ls -la", # leading whitespace still counts
|
||||
"! spaced out", # `!` followed by spaces
|
||||
"!!", # double bang
|
||||
"!", # bare bang
|
||||
])
|
||||
def test_leading_bang_is_bang(self, text):
|
||||
assert is_bang_command(text) is True
|
||||
|
||||
@pytest.mark.parametrize("text", [
|
||||
"fix the bug!", # trailing `!` in prose
|
||||
"echo hi! please", # mid-text `!`
|
||||
"run this: !ls", # `!` not at the start
|
||||
"/help",
|
||||
"hello world",
|
||||
"",
|
||||
" ",
|
||||
None,
|
||||
123,
|
||||
])
|
||||
def test_non_leading_bang_is_not_bang(self, text):
|
||||
assert is_bang_command(text) is False
|
||||
|
||||
@pytest.mark.parametrize("text,expected", [
|
||||
("!ls", "ls"),
|
||||
("!git status", "git status"),
|
||||
(" !ls -la ", "ls -la"),
|
||||
("! echo hi", "echo hi"),
|
||||
("!!", "!"), # second bang belongs to the command
|
||||
("!!ls", "!ls"),
|
||||
("!", ""), # bare bang → no command
|
||||
("! ", ""),
|
||||
("not a bang", ""),
|
||||
])
|
||||
def test_parse_strips_exactly_one_bang(self, text, expected):
|
||||
assert parse_bang_command(text) == expected
|
||||
|
||||
|
||||
class TestBangContextGating:
|
||||
"""Bang mode is CLI-only — gateway/cron users have their own shells."""
|
||||
|
||||
def test_enabled_in_plain_cli(self, monkeypatch):
|
||||
for var in ("HERMES_GATEWAY_SESSION", "HERMES_CRON_SESSION",
|
||||
"HERMES_SESSION_PLATFORM"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
assert bang_shell_enabled() is True
|
||||
|
||||
@pytest.mark.parametrize("var,value", [
|
||||
("HERMES_GATEWAY_SESSION", "1"),
|
||||
("HERMES_CRON_SESSION", "true"),
|
||||
("HERMES_SESSION_PLATFORM", "discord"),
|
||||
])
|
||||
def test_disabled_in_non_cli_contexts(self, monkeypatch, var, value):
|
||||
for v in ("HERMES_GATEWAY_SESSION", "HERMES_CRON_SESSION",
|
||||
"HERMES_SESSION_PLATFORM"):
|
||||
monkeypatch.delenv(v, raising=False)
|
||||
monkeypatch.setenv(var, value)
|
||||
assert bang_shell_enabled() is False
|
||||
|
||||
|
||||
# ── execution ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestBangExecution:
|
||||
def test_output_is_streamed_to_writer(self):
|
||||
lines = []
|
||||
code = run_bang_command("echo bang-one; echo bang-two", writer=lines.append)
|
||||
assert code == 0
|
||||
assert "bang-one" in lines
|
||||
assert "bang-two" in lines
|
||||
|
||||
def test_stderr_is_merged_into_output(self):
|
||||
lines = []
|
||||
run_bang_command("echo to-stderr >&2", writer=lines.append)
|
||||
assert "to-stderr" in lines
|
||||
|
||||
def test_nonzero_exit_code_is_returned(self):
|
||||
lines = []
|
||||
code = run_bang_command("exit 42", writer=lines.append)
|
||||
assert code == 42
|
||||
|
||||
def test_runs_in_requested_cwd(self, tmp_path):
|
||||
lines = []
|
||||
code = run_bang_command("pwd", cwd=str(tmp_path), writer=lines.append)
|
||||
assert code == 0
|
||||
# macOS resolves /tmp through /private, so compare realpaths.
|
||||
assert os.path.realpath(lines[-1].strip()) == os.path.realpath(str(tmp_path))
|
||||
|
||||
def test_missing_cwd_falls_back_without_crashing(self, tmp_path):
|
||||
lines = []
|
||||
code = run_bang_command(
|
||||
"echo ok", cwd=str(tmp_path / "does-not-exist"), writer=lines.append
|
||||
)
|
||||
assert code == 0
|
||||
assert "ok" in lines
|
||||
|
||||
|
||||
# ── CLI handler: approval gate, usage hint, exit codes ─────────────────────
|
||||
|
||||
def _make_cli(history=None):
|
||||
"""Build a HermesCLI shell with only what handle_bang_shell touches."""
|
||||
from cli import HermesCLI
|
||||
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
cli.config = {}
|
||||
cli.console = MagicMock()
|
||||
cli.agent = None
|
||||
cli.session_id = "test-session"
|
||||
cli.conversation_history = [] if history is None else history
|
||||
cli._app = None
|
||||
return cli
|
||||
|
||||
|
||||
def _printed(cli):
|
||||
"""All text the CLI printed, flattened to plain strings."""
|
||||
out = []
|
||||
for call in cli.console.print.call_args_list:
|
||||
if not call.args:
|
||||
continue
|
||||
arg = call.args[0]
|
||||
out.append(getattr(arg, "plain", None) or str(arg))
|
||||
return out
|
||||
|
||||
|
||||
class TestBangHandlerDispatch:
|
||||
def test_non_bang_text_is_not_handled(self):
|
||||
cli = _make_cli()
|
||||
assert cli.handle_bang_shell("please fix this bug!") is False
|
||||
assert cli.handle_bang_shell("/help") is False
|
||||
|
||||
def test_bare_bang_prints_usage_and_runs_nothing(self):
|
||||
cli = _make_cli()
|
||||
with patch("hermes_cli.bang_shell.run_bang_command") as runner:
|
||||
assert cli.handle_bang_shell("!") is True
|
||||
runner.assert_not_called()
|
||||
assert any(USAGE_HINT in line for line in _printed(cli))
|
||||
|
||||
def test_command_output_is_printed(self):
|
||||
cli = _make_cli()
|
||||
assert cli.handle_bang_shell("!echo hello-bang") is True
|
||||
assert any("hello-bang" in line for line in _printed(cli))
|
||||
|
||||
def test_nonzero_exit_is_surfaced_to_the_user(self):
|
||||
cli = _make_cli()
|
||||
assert cli.handle_bang_shell("!exit 3") is True
|
||||
assert any("exited 3" in line for line in _printed(cli))
|
||||
|
||||
def test_zero_exit_prints_no_exit_line(self):
|
||||
cli = _make_cli()
|
||||
cli.handle_bang_shell("!true")
|
||||
assert not any("exited" in line for line in _printed(cli))
|
||||
|
||||
def test_disabled_context_falls_through(self, monkeypatch):
|
||||
"""Gateway sessions must not execute bang commands."""
|
||||
cli = _make_cli()
|
||||
monkeypatch.setenv("HERMES_GATEWAY_SESSION", "1")
|
||||
with patch("hermes_cli.bang_shell.run_bang_command") as runner:
|
||||
assert cli.handle_bang_shell("!echo nope") is False
|
||||
runner.assert_not_called()
|
||||
|
||||
|
||||
class TestBangApprovalGate:
|
||||
"""A user-typed command still goes through the terminal tool's gate."""
|
||||
|
||||
def test_approval_gate_is_invoked_for_a_dangerous_command(self):
|
||||
cli = _make_cli()
|
||||
gate = MagicMock(return_value={"approved": True, "message": None})
|
||||
with patch("tools.terminal_tool._check_all_guards", gate), \
|
||||
patch("hermes_cli.bang_shell.run_bang_command", return_value=0):
|
||||
cli.handle_bang_shell("!rm -rf ./build")
|
||||
|
||||
gate.assert_called_once()
|
||||
assert gate.call_args.args[0] == "rm -rf ./build"
|
||||
|
||||
def test_gate_is_invoked_for_every_command_not_just_dangerous_ones(self):
|
||||
cli = _make_cli()
|
||||
gate = MagicMock(return_value={"approved": True, "message": None})
|
||||
with patch("tools.terminal_tool._check_all_guards", gate), \
|
||||
patch("hermes_cli.bang_shell.run_bang_command", return_value=0):
|
||||
cli.handle_bang_shell("!ls")
|
||||
gate.assert_called_once()
|
||||
|
||||
def test_denied_command_is_not_executed(self):
|
||||
cli = _make_cli()
|
||||
gate = MagicMock(return_value={
|
||||
"approved": False,
|
||||
"message": "Command denied: recursive delete",
|
||||
})
|
||||
with patch("tools.terminal_tool._check_all_guards", gate), \
|
||||
patch("hermes_cli.bang_shell.run_bang_command") as runner:
|
||||
assert cli.handle_bang_shell("!rm -rf /important") is True
|
||||
|
||||
runner.assert_not_called()
|
||||
assert any("denied" in line.lower() for line in _printed(cli))
|
||||
|
||||
def test_real_gate_blocks_a_hardline_command(self):
|
||||
"""End-to-end through the real approval module — no execution."""
|
||||
cli = _make_cli()
|
||||
with patch("hermes_cli.bang_shell.run_bang_command") as runner:
|
||||
assert cli.handle_bang_shell("!rm -rf /") is True
|
||||
runner.assert_not_called()
|
||||
|
||||
|
||||
# ── THE load-bearing invariant ─────────────────────────────────────────────
|
||||
|
||||
_SEED_HISTORY = [
|
||||
{"role": "system", "content": "You are Hermes."},
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "Hi there."},
|
||||
{"role": "user", "content": "list the files"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "terminal", "arguments": '{"command": "ls"}'},
|
||||
}],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call_1", "content": "a.py b.py"},
|
||||
]
|
||||
|
||||
|
||||
class TestBangLeavesHistoryByteIdentical:
|
||||
"""Nothing about a bang command may enter conversation history.
|
||||
|
||||
This is what makes `!` free (zero tokens, prompt cache untouched) and
|
||||
unable to break role alternation. Compared as serialized JSON so an added,
|
||||
removed, or mutated message anywhere in the list fails the assertion.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize("submission", [
|
||||
"!echo history-check", # succeeds
|
||||
"!exit 7", # non-zero exit
|
||||
"!", # bare bang / usage hint
|
||||
"!definitely-not-a-real-binary-xyz", # command not found
|
||||
])
|
||||
def test_history_is_byte_identical_before_and_after(self, submission):
|
||||
cli = _make_cli(history=copy.deepcopy(_SEED_HISTORY))
|
||||
before = json.dumps(cli.conversation_history, sort_keys=True)
|
||||
|
||||
cli.handle_bang_shell(submission)
|
||||
|
||||
after = json.dumps(cli.conversation_history, sort_keys=True)
|
||||
assert after == before, (
|
||||
f"bang command {submission!r} mutated conversation history"
|
||||
)
|
||||
assert len(cli.conversation_history) == len(_SEED_HISTORY)
|
||||
|
||||
def test_history_unchanged_when_command_is_denied(self):
|
||||
cli = _make_cli(history=copy.deepcopy(_SEED_HISTORY))
|
||||
before = json.dumps(cli.conversation_history, sort_keys=True)
|
||||
|
||||
gate = MagicMock(return_value={"approved": False, "message": "nope"})
|
||||
with patch("tools.terminal_tool._check_all_guards", gate):
|
||||
cli.handle_bang_shell("!rm -rf /important")
|
||||
|
||||
assert json.dumps(cli.conversation_history, sort_keys=True) == before
|
||||
|
||||
def test_agent_is_never_invoked(self):
|
||||
"""No model turn: the agent object is not touched at all."""
|
||||
cli = _make_cli(history=copy.deepcopy(_SEED_HISTORY))
|
||||
agent = MagicMock()
|
||||
cli.agent = agent
|
||||
|
||||
cli.handle_bang_shell("!echo no-model-turn")
|
||||
|
||||
# No chat/steer/run/redirect call of any kind.
|
||||
assert agent.mock_calls == []
|
||||
assert json.dumps(cli.conversation_history, sort_keys=True) == json.dumps(
|
||||
_SEED_HISTORY, sort_keys=True
|
||||
)
|
||||
|
||||
def test_role_alternation_is_preserved_across_many_bangs(self):
|
||||
cli = _make_cli(history=copy.deepcopy(_SEED_HISTORY))
|
||||
before = json.dumps(cli.conversation_history, sort_keys=True)
|
||||
|
||||
for _ in range(5):
|
||||
cli.handle_bang_shell("!echo repeated")
|
||||
|
||||
assert json.dumps(cli.conversation_history, sort_keys=True) == before
|
||||
roles = [m["role"] for m in cli.conversation_history]
|
||||
assert roles == ["system", "user", "assistant", "user", "assistant", "tool"]
|
||||
|
||||
|
||||
|
||||
@@ -112,9 +112,29 @@ When resuming a previous session (`hermes -c` or `hermes --resume <id>`), a "Pre
|
||||
| `Ctrl+D` | Exit |
|
||||
| `Ctrl+Z` | Suspend Hermes to background (Unix only). Run `fg` in the shell to resume. |
|
||||
| `Tab` | Accept auto-suggestion (ghost text) or autocomplete slash commands |
|
||||
| `!<command>` | **Shell mode** — run a shell command yourself without spending a model turn (e.g. `!git status`, `!pytest -x`). See below. |
|
||||
|
||||
**Multiline paste preview.** When you paste a multi-line block, the CLI echoes a compact single-line preview (`[pasted: 47 lines, 1,842 chars — press Enter to send]`) instead of dumping the whole payload into the scrollback. The full content is still what gets sent; this is just display polish.
|
||||
|
||||
### `!` Shell Mode
|
||||
|
||||
Start a line with `!` to run it as a shell command instead of sending it to the agent:
|
||||
|
||||
```
|
||||
> !git status
|
||||
> !ls -la
|
||||
> !pytest -x tests/cli
|
||||
```
|
||||
|
||||
- **Zero cost.** The model is never invoked — no API call, no tokens, no latency.
|
||||
- **Nothing enters the conversation.** The command and its output are not added to history, so your context stays clean and the prompt cache is untouched.
|
||||
- **Runs where the agent's `terminal` tool runs.** Uses the session working directory, so `!pwd` matches what the agent would see.
|
||||
- **Approvals still apply.** A dangerous command (`rm -rf`, writes to `~/.hermes/config.yaml`, etc.) goes through the same approval prompt the agent's `terminal` tool uses. `!` is a cost/latency shortcut, not a security bypass.
|
||||
- **Non-zero exits are shown.** A failing command prints `! exited <code>` after its output.
|
||||
- `!` on its own prints a one-line usage reminder.
|
||||
|
||||
Shell mode is CLI-only. Gateway platforms (Discord, Telegram, Slack) and cron runs ignore it — those users already have their own shells.
|
||||
|
||||
**Markdown stripping in final responses.** The CLI strips the most verbose markdown fences and `**bold**` / `*italic*` wrappers from *final* agent replies so they render as readable terminal prose rather than raw source. Code blocks and lists are preserved. This does not affect gateway platforms or tool results — they keep their markdown for native rendering.
|
||||
|
||||
## Slash Commands
|
||||
|
||||
Reference in New Issue
Block a user