Skip to content
Merged
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
3 changes: 3 additions & 0 deletions agent/background_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,9 @@ def _bg_review_auto_deny(command, description, **kwargs):
max_iterations=16,
quiet_mode=True,
platform=agent.platform,
chat_id=getattr(agent, "_chat_id", None) or "",
chat_name=getattr(agent, "_chat_name", None) or "",
chat_type=getattr(agent, "_chat_type", None) or "",
provider=agent.provider,
api_mode=_parent_api_mode,
base_url=_parent_runtime.get("base_url") or None,
Expand Down
45 changes: 45 additions & 0 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -12727,8 +12727,53 @@ def run_agent():
if tts_thread is not None and tts_thread.is_alive():
tts_thread.join(timeout=5)

def _clear_terminal_on_exit(self):
"""Clear screen + scrollback so nothing is stranded above the exit summary.

Called from ``_print_exit_summary`` after ``app.run()`` has returned and
prompt_toolkit has torn down its renderer + restored terminal modes —
so a direct write to the real stdout fd is safe (the StdoutProxy /
patch_stdout layer is gone by now).

Sequence: ``ESC[3J`` (erase scrollback) + ``ESC[2J`` (erase visible
screen) + ``ESC[H`` (cursor home). Modern terminals on Linux, macOS and
Windows (Terminal / conhost with VT processing, which prompt_toolkit
already enables) all honor these. Best-effort: skip silently when
stdout isn't a real console, and fall back to the platform ``clear`` /
``cls`` command if the escape write fails.
"""
try:
stream = sys.stdout
if stream is None or not stream.isatty():
return
except Exception:
return
try:
stream.write("\033[3J\033[2J\033[H")
stream.flush()
return
except Exception:
pass
# Fallback: shell clear command (rarely needed — escapes work on every
# VT-capable terminal, but this covers exotic stdout wrappers).
try:
os.system("cls" if os.name == "nt" else "clear")
except Exception:
pass

def _print_exit_summary(self):
"""Print session resume info on exit, similar to Claude Code."""
# Clear the screen + scrollback before printing the summary so the
# live bottom chrome (status bar, input box, separator rules) and the
# rest of the session transcript don't get stranded above the exit
# summary (#38252). By this point app.run() has returned and
# prompt_toolkit has restored terminal modes, so writing raw escapes
# to stdout is safe. ESC[3J clears scrollback, ESC[2J clears the
# visible screen, ESC[H homes the cursor — so the summary prints at a
# clean top-left. Falls back to the platform clear command if stdout
# isn't a TTY-capable stream. Honors NO_COLOR/dumb terminals by
# skipping silently when there's no real console.
self._clear_terminal_on_exit()
print()
msg_count = len(self.conversation_history)
if msg_count > 0:
Expand Down
61 changes: 61 additions & 0 deletions tests/run_agent/test_background_review_cache_parity.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ def _make_agent_stub(agent_cls):
# Non-None so the test catches a missing-kwarg regression.
agent.enabled_toolsets = ["memory", "skills", "terminal"]
agent.disabled_toolsets = ["spotify", "feishu_doc"]
# Chat context the review fork must inherit so its blackbox row is attributable.
agent._chat_id = "571820863"
agent._chat_name = "Daemonarchy / #aegis"
agent._chat_type = "channel"
return agent


Expand Down Expand Up @@ -237,3 +241,60 @@ def close(self):
f"disabled_toolsets mismatch: {captured.get('disabled_toolsets')!r} "
f"vs expected {agent.disabled_toolsets!r}"
)


def test_review_fork_inherits_parent_chat_context():
"""Blackbox attribution: fork must inherit parent's chat_id/chat_name/chat_type.

Regression for background_review rows landing with empty chat fields, which
made them un-attributable in /cost session and the blackbox DB.
"""
import run_agent

agent = _make_agent_stub(run_agent.AIAgent)

captured = {}

class _Recorder:
def __init__(self, *args, **kwargs):
captured["chat_id"] = kwargs.get("chat_id")
captured["chat_name"] = kwargs.get("chat_name")
captured["chat_type"] = kwargs.get("chat_type")
self._cached_system_prompt = None
self._memory_write_origin = None
self._memory_write_context = None
self._memory_store = None
self._memory_enabled = None
self._user_profile_enabled = None
self._memory_nudge_interval = None
self._skill_nudge_interval = None
self.suppress_status_output = None
self.session_start = None
self.session_id = None

def run_conversation(self, *args, **kwargs):
raise RuntimeError("stop after recording — don't actually call the API")

def shutdown_memory_provider(self):
pass

def close(self):
pass

with patch.object(run_agent, "AIAgent", _Recorder), \
patch("threading.Thread", _SyncThread):
agent._spawn_background_review(
messages_snapshot=[],
review_memory=True,
review_skills=False,
)

assert captured.get("chat_id") == "571820863", (
f"chat_id not inherited: {captured.get('chat_id')!r}"
)
assert captured.get("chat_name") == "Daemonarchy / #aegis", (
f"chat_name not inherited: {captured.get('chat_name')!r}"
)
assert captured.get("chat_type") == "channel", (
f"chat_type not inherited: {captured.get('chat_type')!r}"
)
80 changes: 80 additions & 0 deletions tests/tools/test_hardline_blocklist.py
Original file line number Diff line number Diff line change
Expand Up @@ -374,3 +374,83 @@ def test_sudo_stdin_guard_container_bypass(clean_session):
for cmd in _SUDO_STDIN_BLOCK:
result = check_all_command_guards(cmd, env)
assert result["approved"] is True, f"container {env} should bypass sudo guard on {cmd!r}"


# -------------------------------------------------------------------------
# HERMES_ALLOW_REBOOT env-gated downgrade (reboot/shutdown only)
# -------------------------------------------------------------------------

_REBOOT_FAMILY = [
"reboot",
"sudo reboot",
"systemctl reboot",
"shutdown -h now",
"shutdown -r now",
"halt",
"poweroff",
"init 0",
"init 6",
"telinit 0",
"systemctl poweroff",
]

# Catastrophic commands that must NEVER downgrade, flag or no flag.
_NEVER_DOWNGRADE = [
"rm -rf /",
"mkfs.ext4 /dev/sda1",
"dd if=/dev/zero of=/dev/sda",
":(){ :|:& };:",
"kill -1",
]


@pytest.mark.parametrize("command", _REBOOT_FAMILY)
def test_reboot_stays_hardline_when_flag_unset(command, monkeypatch):
"""Default (HERMES_ALLOW_REBOOT unset): reboot/shutdown stay hardline."""
monkeypatch.delenv("HERMES_ALLOW_REBOOT", raising=False)
is_hl, desc = detect_hardline_command(command)
assert is_hl, f"{command!r} must stay hardline when flag unset"
assert desc


@pytest.mark.parametrize("command", _REBOOT_FAMILY)
def test_reboot_downgrades_to_dangerous_when_flag_set(command, monkeypatch):
"""HERMES_ALLOW_REBOOT=1: reboot/shutdown drop out of hardline but remain
dangerous (approval-gated; yolo can pass)."""
monkeypatch.setenv("HERMES_ALLOW_REBOOT", "1")
is_hl, _ = detect_hardline_command(command)
assert not is_hl, f"{command!r} must downgrade out of hardline when flag set"
is_dng, _, ddesc = detect_dangerous_command(command)
assert is_dng, f"{command!r} must remain dangerous (approval-gated) when downgraded"
assert ddesc


@pytest.mark.parametrize("command", _NEVER_DOWNGRADE)
def test_other_hardline_unaffected_by_reboot_flag(command, monkeypatch):
"""The reboot flag must NOT loosen any other catastrophic pattern."""
monkeypatch.setenv("HERMES_ALLOW_REBOOT", "1")
is_hl, desc = detect_hardline_command(command)
assert is_hl, f"{command!r} must stay hardline even with HERMES_ALLOW_REBOOT=1"
assert desc


def test_reboot_flag_plus_yolo_approves(monkeypatch):
"""Flag set + session yolo => reboot passes the full guard pipeline."""
monkeypatch.setenv("HERMES_ALLOW_REBOOT", "1")
monkeypatch.delenv("HERMES_YOLO_MODE", raising=False)
token = set_current_session_key("reboot_flag_test")
try:
enable_session_yolo("reboot_flag_test")
res = check_all_command_guards("sudo reboot", "ssh")
assert res["approved"] is True, f"expected approval, got {res}"
finally:
disable_session_yolo("reboot_flag_test")
reset_current_session_key(token)


def test_reboot_flag_falsey_values_stay_blocked(monkeypatch):
"""Only truthy HERMES_ALLOW_REBOOT downgrades; falsey/empty stays blocked."""
for val in ["0", "false", "no", ""]:
monkeypatch.setenv("HERMES_ALLOW_REBOOT", val)
is_hl, _ = detect_hardline_command("reboot")
assert is_hl, f"HERMES_ALLOW_REBOOT={val!r} must NOT downgrade reboot"
43 changes: 43 additions & 0 deletions tools/approval.py
Original file line number Diff line number Diff line change
Expand Up @@ -317,15 +317,50 @@ def _check_sudo_stdin_guard(command: str) -> tuple:
return (False, None)


# Reboot/shutdown hardline descriptions that may be downgraded to the
# DANGEROUS layer when HERMES_ALLOW_REBOOT is opted into. Kept in sync with
# the reboot/shutdown entries in HARDLINE_PATTERNS below.
_REBOOT_HARDLINE_DESCS = {
"system shutdown/reboot",
"init 0/6 (shutdown/reboot)",
"systemctl poweroff/reboot",
"telinit 0/6 (shutdown/reboot)",
}


def _reboot_shutdown_allowed() -> bool:
"""Whether the reboot/shutdown family is opted out of the hardline floor.

When ``HERMES_ALLOW_REBOOT`` is set truthy, reboot/shutdown commands
downgrade from the unconditional hardline block to the DANGEROUS layer
(approval-gated; yolo / approvals.mode=off can pass them). This is for
fleet/ops agents that legitimately need to reboot hosts they manage.
Default (unset) preserves the historical unconditional block — no other
catastrophic pattern is affected.
"""
from utils import env_var_enabled
return env_var_enabled("HERMES_ALLOW_REBOOT")


def detect_hardline_command(command: str) -> tuple:
"""Check if a command matches the unconditional hardline blocklist.

Returns:
(is_hardline, description) or (False, None)
"""
normalized = _normalize_command_for_detection(command).lower()
allow_reboot = _reboot_shutdown_allowed()
for pattern_re, description in HARDLINE_PATTERNS_COMPILED:
if pattern_re.search(normalized):
# Opt-in escape hatch: when HERMES_ALLOW_REBOOT is set, the
# reboot/shutdown family downgrades out of the hardline floor and
# is handled by the DANGEROUS_PATTERNS layer instead (approval-
# gated; yolo can pass it through). Every other catastrophic
# pattern (rm -rf /, mkfs, dd to raw device, fork bomb, kill -1)
# stays unconditionally hardline. Default (flag unset) is byte-
# identical to the historical always-block behavior.
if allow_reboot and description in _REBOOT_HARDLINE_DESCS:
continue
return (True, description)
return (False, None)

Expand Down Expand Up @@ -382,6 +417,14 @@ def _sudo_stdin_block_result(description: str) -> dict:
(r'\bTRUNCATE\s+(TABLE)?\s*\w', "SQL TRUNCATE"),
(rf'>\s*{_SYSTEM_CONFIG_PATH}', "overwrite system config"),
(r'\bsystemctl\s+(-[^\s]+\s+)*(stop|restart|disable|mask)\b', "stop/restart system service"),
# Reboot/shutdown family — normally HARDLINE-blocked. They only reach this
# DANGEROUS layer when HERMES_ALLOW_REBOOT downgrades them (see
# detect_hardline_command). Listed here so that, once downgraded, they are
# still approval-gated (and yolo-bypassable) rather than silently allowed.
(_CMDPOS + r'(shutdown|reboot|halt|poweroff)\b', "system shutdown/reboot"),
(_CMDPOS + r'init\s+[06]\b', "init 0/6 (shutdown/reboot)"),
(_CMDPOS + r'systemctl\s+(poweroff|reboot|halt|kexec)\b', "systemctl poweroff/reboot"),
(_CMDPOS + r'telinit\s+[06]\b', "telinit 0/6 (shutdown/reboot)"),
(r'\bkill\s+-9\s+-1\b', "kill all processes"),
(r'\bpkill\s+-9\b', "force kill processes"),
# killall with SIGKILL (parallel to pkill -9). Catches -9 / -KILL /
Expand Down
18 changes: 9 additions & 9 deletions website/docs/reference/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -418,21 +418,21 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI

### Web Dashboard & Hermes Desktop

Auth for the [web dashboard](/user-guide/features/web-dashboard) and for connecting [Hermes Desktop to a remote backend](/user-guide/features/web-dashboard#connecting-hermes-desktop-to-a-remote-backend). Per the secrets-only convention, the token belongs in `~/.hermes/.env`; the OAuth `client_id`/`portal_url` are better set under `dashboard.oauth` in `config.yaml` (env wins when set).
Auth for the [web dashboard](/user-guide/features/web-dashboard) and for connecting [Hermes Desktop to a remote backend](/user-guide/features/web-dashboard#connecting-hermes-desktop-to-a-remote-backend). Per the secrets-only convention, credentials belong in `~/.hermes/.env`; the OAuth `client_id`/`portal_url` are better set under `dashboard.oauth` in `config.yaml` (env wins when set).

The recommended way to expose a dashboard for a remote Hermes Desktop connection is the bundled **username/password** provider: set the `HERMES_DASHBOARD_BASIC_AUTH_*` vars below and run `hermes dashboard --host 0.0.0.0`. The non-loopback bind engages the auth gate, and Desktop signs in with the username and password.

| Variable | Description |
|----------|-------------|
| `HERMES_DASHBOARD_SESSION_TOKEN` | Pins the dashboard session token instead of generating a random one per boot. Set this (e.g. `openssl rand -base64 32`) on the backend, then paste the same value into Hermes Desktop → Settings → Gateway → Remote gateway → Session token. Required for a stable remote desktop connection. |
| `HERMES_DESKTOP_REMOTE_URL` | (Desktop side) Base URL of the remote backend, e.g. `http://host:9119`. When set, overrides the in-app Gateway settings. Must be paired with `HERMES_DESKTOP_REMOTE_TOKEN`. |
| `HERMES_DESKTOP_REMOTE_TOKEN` | (Desktop side) The session token to authenticate with the remote backend — the same value as the backend's `HERMES_DASHBOARD_SESSION_TOKEN`. |
| `HERMES_DASHBOARD_OAUTH_CLIENT_ID` | OAuth client id (`agent:{instance_id}`) for the gated/public dashboard. Overrides `dashboard.oauth.client_id`. Provisioned by the Nous Portal for hosted deploys. |
| `HERMES_DASHBOARD_PORTAL_URL` | OAuth portal URL (default: `https://portal.nousresearch.com`). Override only for staging/custom deploys. |
| `HERMES_DASHBOARD_PUBLIC_URL` | Complete public URL the dashboard is reached at, for OAuth callback construction behind reverse proxies. Overrides `dashboard.public_url`. |
| `HERMES_DASHBOARD_BASIC_AUTH_USERNAME` | Username for the bundled username/password dashboard-auth provider (`plugins/dashboard_auth/basic`). Activates the provider when set together with a password. Overrides `dashboard.basic_auth.username`. |
| `HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH` | scrypt password hash for the basic provider (preferred — no plaintext at rest). Compute with `python -c "from plugins.dashboard_auth.basic import hash_password; print(hash_password('PW'))"`. Overrides `dashboard.basic_auth.password_hash`. |
| `HERMES_DASHBOARD_BASIC_AUTH_PASSWORD` | Plaintext password for the basic provider (hashed in-memory at load). Wins over a config `password_hash` so you can rotate via env. Overrides `dashboard.basic_auth.password`. |
| `HERMES_DASHBOARD_BASIC_AUTH_SECRET` | HMAC key (32+ bytes, base64/hex/raw) signing the basic provider's stateless session tokens. Set explicitly for restart-surviving / multi-worker sessions; blank → random per-process. Overrides `dashboard.basic_auth.secret`. |
| `HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH` | scrypt password hash for the basic provider (preferred — no plaintext at rest). Compute with `python -c "from plugins.dashboard_auth.basic import hash_password; print(hash_password('PW'))"`. Overrides `dashboard.basic_auth.password_hash`. |
| `HERMES_DASHBOARD_BASIC_AUTH_SECRET` | HMAC key (32+ bytes, base64/hex/raw) signing the basic provider's stateless session tokens. Set explicitly so sessions survive restarts / span multiple workers; blank → random per-process (you'll be logged out on every restart). Overrides `dashboard.basic_auth.secret`. |
| `HERMES_DASHBOARD_BASIC_AUTH_TTL_SECONDS` | Access-token lifetime for the basic provider (default 12h). Overrides `dashboard.basic_auth.session_ttl_seconds`. |
| `HERMES_DESKTOP_REMOTE_URL` | (Desktop side) Base URL of the remote backend, e.g. `http://host:9119`. When set, overrides the in-app Gateway URL; you still sign in with your username and password from the Gateway settings panel. |
| `HERMES_DASHBOARD_OAUTH_CLIENT_ID` | OAuth client id (`agent:{instance_id}`) for the gated/public dashboard. Overrides `dashboard.oauth.client_id`. Provisioned by the Nous Portal for hosted deploys. |
| `HERMES_DASHBOARD_PORTAL_URL` | OAuth portal URL (default: `https://portal.nousresearch.com`). Override only for staging/custom deploys. |
| `HERMES_DASHBOARD_PUBLIC_URL` | Complete public URL the dashboard is reached at, for OAuth callback construction behind reverse proxies. Overrides `dashboard.public_url`. |

### Microsoft Graph (Teams Meetings)

Expand Down
Loading
Loading