mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-12 13:52:15 +00:00
## What does this PR do?
Closes a critical hole in the hardline command floor. HARDLINE_PATTERNS is
the unconditional last line of defense: detect_hardline_command runs BEFORE
every yolo / approvals.mode=off / cron approve-mode bypass, so it is the only
gate standing between the agent (or a prompt-injected instruction) and an
irrecoverable disk wipe. The three rm rules anchored on a bare path token,
and _normalize_command_for_detection never strips shell quotes — so the
ordinary, recommended shell idioms slipped straight through:
rm -rf "/" rm -rf '/' rm -rf "/etc"
rm -rf "$HOME" rm -rf ${HOME} rm -rf "${HOME}"
All of these returned NO hardline match. A leading quote pushes the path out
of reach of the flag group, a trailing quote breaks the `(\s|$)` terminator,
and the `${HOME}` brace form was never listed at all. Under --yolo,
approvals.mode=off, or cron approve-mode the dangerous-command layer is also
skipped, so these commands reached execution with zero gate — exactly the
unrecoverable data loss the floor is documented to make impossible. Because
quoting paths and `${HOME}` are normal shell usage, not exotic obfuscation,
this is a high-severity, easily-triggered bypass.
The fix makes the rm path matcher quote- and brace-tolerant while staying
conservative: a path is matched when it is either fully wrapped in its own
matching quote pair (`"/"`) or bare with a whitespace/end terminator. The
matching-quote requirement is deliberate so the change adds no new false
positives — a dangerous-looking string that is merely an argument to another
command (e.g. `git commit -m "rm -rf /"`) has a closing quote but no opening
quote of its own around the path, so neither branch fires.
## Related Issue
N/A
## Type of Change
- [x] 🔒 Security fix
## Changes Made
- `tools/approval.py`: added `_hardline_rm_path()` (matches a destructive
path either fully quoted or bare-with-terminator), factored the protected
system-dir list into `_HARDLINE_SYSTEM_DIRS` and the rm flag prefix into
`_RM_FLAG_PREFIX`, and rebuilt the three rm `HARDLINE_PATTERNS` on top of
them, adding the `${HOME}` brace form. Kept as plain concatenation so regex
backslashes never land inside an f-string field (Python 3.11 floor).
- `tests/tools/test_hardline_blocklist.py`: added quoted (`"/"`, `'/'`,
`"/etc"`, `"$HOME"`, ...) and brace (`${HOME}`, `"${HOME}"`) cases to the
must-block set, a dedicated `_QUOTED_BRACE_BYPASS` regression parametrization,
no-false-positive guards (`git commit -m "rm -rf /"`), and extended the
yolo-cannot-bypass integration test to cover the quoted/brace forms.
## How to Test
1. Reproduce the bypass on `main`: `detect_hardline_command('rm -rf "/"')`
returns `(False, None)` — the floor lets it through.
2. With this change it returns `(True, "recursive delete of root filesystem")`;
the same holds for `'/'`, `"/etc"`, `"$HOME"`, `${HOME}`, `"${HOME}"`.
3. Run the suite: `scripts/run_tests.sh tests/tools/test_hardline_blocklist.py`
— 125 passed, including the new bypass and no-false-positive cases.
## Checklist
### Code
- [x] I've read the Contributing Guide
- [x] My commit messages follow Conventional Commits (`fix(scope):`, etc.)
- [x] I searched for existing PRs to make sure this isn't a duplicate
- [x] My PR contains **only** changes related to this fix (no unrelated commits)
- [x] I've run the relevant tests and they pass
- [x] I've added tests for my changes (required for bug fixes)
- [x] I've tested on my platform: macOS 15 (Darwin 25.5)
### Documentation & Housekeeping
- [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A
- [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A
- [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A
- [x] I've considered cross-platform impact (Windows, macOS) — pattern-only change, ruff + footgun gate pass
- [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A
427 lines
15 KiB
Python
427 lines
15 KiB
Python
"""Tests for the unconditional hardline command blocklist.
|
|
|
|
The hardline list is a floor below yolo: a small set of commands so
|
|
catastrophic they should never run via the agent, regardless of --yolo,
|
|
gateway /yolo, approvals.mode=off, or cron approve mode.
|
|
|
|
Inspired by Mercury Agent's permission-hardened blocklist.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from tools.approval import (
|
|
HARDLINE_PATTERNS,
|
|
check_all_command_guards,
|
|
check_dangerous_command,
|
|
detect_dangerous_command,
|
|
detect_hardline_command,
|
|
disable_session_yolo,
|
|
enable_session_yolo,
|
|
reset_current_session_key,
|
|
set_current_session_key,
|
|
)
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Pattern detection
|
|
# -------------------------------------------------------------------------
|
|
|
|
# Commands that MUST be hardline-blocked.
|
|
_HARDLINE_BLOCK = [
|
|
# rm -rf targeting root / system dirs / home
|
|
"rm -rf /",
|
|
"rm -rf /*",
|
|
"rm -rf /home",
|
|
"rm -rf /home/*",
|
|
"rm -rf /etc",
|
|
"rm -rf /usr",
|
|
"rm -rf /var",
|
|
"rm -rf /boot",
|
|
"rm -rf /bin",
|
|
"rm --recursive --force /",
|
|
"rm -fr /",
|
|
"sudo rm -rf /",
|
|
"rm -rf ~",
|
|
"rm -rf ~/",
|
|
"rm -rf ~/*",
|
|
"rm -rf $HOME",
|
|
# Quoted path idioms — the recommended shell form for paths with special
|
|
# chars. These previously slipped past the floor because the surrounding
|
|
# quote broke both the flag group and the (\s|$) terminator (regression
|
|
# guard: catastrophic disk/home wipe under --yolo / approvals.mode=off).
|
|
'rm -rf "/"',
|
|
"rm -rf '/'",
|
|
'rm -rf "/*"',
|
|
'rm -rf "/etc"',
|
|
"rm -rf '/etc'",
|
|
'rm -rf "/home"',
|
|
'rm -rf "/usr"',
|
|
'rm -rf "$HOME"',
|
|
"rm -rf '$HOME'",
|
|
'rm -rf "$HOME/"',
|
|
'rm -rf "~"',
|
|
'sudo rm -rf "/"',
|
|
'rm -rf "/" && echo done',
|
|
# ${HOME} brace form (universally common, previously unmatched).
|
|
"rm -rf ${HOME}",
|
|
'rm -rf "${HOME}"',
|
|
"rm -fr ${HOME}",
|
|
# Filesystem format
|
|
"mkfs.ext4 /dev/sda1",
|
|
"mkfs /dev/sdb",
|
|
"mkfs.xfs /dev/nvme0n1",
|
|
# Raw block device overwrites
|
|
"dd if=/dev/zero of=/dev/sda bs=1M",
|
|
"dd if=/dev/urandom of=/dev/nvme0n1",
|
|
"dd if=anything of=/dev/hda",
|
|
"echo bad > /dev/sda",
|
|
"cat /dev/urandom > /dev/sdb",
|
|
# Fork bomb
|
|
":(){ :|:& };:",
|
|
# System-wide kill
|
|
"kill -9 -1",
|
|
"kill -1",
|
|
# Shutdown / reboot / halt
|
|
"shutdown -h now",
|
|
"shutdown -r now",
|
|
"sudo shutdown now",
|
|
"reboot",
|
|
"sudo reboot",
|
|
"halt",
|
|
"poweroff",
|
|
"init 0",
|
|
"init 6",
|
|
"telinit 0",
|
|
"systemctl poweroff",
|
|
"systemctl reboot",
|
|
"systemctl halt",
|
|
# Compound / subshell variants
|
|
"ls; reboot",
|
|
"echo done && shutdown -h now",
|
|
"false || halt",
|
|
"$(reboot)",
|
|
"`shutdown now`",
|
|
"sudo -E shutdown now",
|
|
"env FOO=1 reboot",
|
|
"exec shutdown",
|
|
"nohup reboot",
|
|
"setsid poweroff",
|
|
]
|
|
|
|
|
|
# Commands that look superficially similar but must NOT be hardline-blocked.
|
|
_HARDLINE_ALLOW = [
|
|
# rm on non-protected paths
|
|
"rm -rf /tmp/foo",
|
|
"rm -rf /tmp/*",
|
|
"rm -rf ./build",
|
|
"rm -rf node_modules",
|
|
"rm -rf /home/user/scratch", # subpath of /home, not /home itself
|
|
"rm -rf ~/Downloads/old",
|
|
"rm -rf $HOME/tmp",
|
|
"rm foo.txt",
|
|
"rm -rf some/path",
|
|
# A dangerous-looking command embedded as a quoted *argument* to another
|
|
# command must not trip the floor: the path is immediately followed by a
|
|
# closing quote with no matching opening quote of its own, so the
|
|
# quote-tolerant matcher must still ignore it (no new false positives).
|
|
'git commit -m "rm -rf /"',
|
|
'git commit -m "wipe with rm -rf /etc"',
|
|
# dd to regular files
|
|
"dd if=/dev/zero of=./image.bin",
|
|
"dd if=./data of=./backup.bin",
|
|
# Redirect to regular files / non-block devices
|
|
"echo done > /tmp/flag",
|
|
"echo test > /dev/null",
|
|
# Reading devices is fine
|
|
"ls /dev/sda",
|
|
"cat /dev/urandom | head -c 10",
|
|
# Unrelated commands that happen to contain the trigger word
|
|
"grep 'shutdown' logs.txt",
|
|
"echo reboot",
|
|
"echo '# init 0 in comment'",
|
|
"cat rebooting.log",
|
|
"echo 'halt and catch fire'",
|
|
"python3 -c 'print(\"shutdown\")'",
|
|
"find . -name '*reboot*'",
|
|
# Word-boundary protection
|
|
"mkfs_helper --version",
|
|
# systemctl non-destructive verbs
|
|
"systemctl status nginx",
|
|
"systemctl restart nginx",
|
|
"systemctl stop nginx",
|
|
"systemctl start nginx",
|
|
# targeted kill
|
|
"kill -9 12345",
|
|
"kill -HUP 1234",
|
|
"pkill python",
|
|
# Ordinary ops
|
|
"git status",
|
|
"npm run build",
|
|
"sudo apt update",
|
|
"curl https://example.com | head",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("command", _HARDLINE_BLOCK)
|
|
def test_hardline_detection_blocks(command):
|
|
is_hl, desc = detect_hardline_command(command)
|
|
assert is_hl, f"expected hardline to match {command!r}"
|
|
assert desc, "hardline match must provide a description"
|
|
|
|
|
|
@pytest.mark.parametrize("command", _HARDLINE_ALLOW)
|
|
def test_hardline_detection_allows(command):
|
|
is_hl, desc = detect_hardline_command(command)
|
|
assert not is_hl, f"expected hardline NOT to match {command!r} (got: {desc})"
|
|
assert desc is None
|
|
|
|
|
|
# Commands written with the ordinary quoting / brace shell idioms that
|
|
# previously slipped past the floor. Kept as an explicit regression set so
|
|
# the intent (quoting `rm -rf "/"` must not be a disk-wipe bypass) survives
|
|
# any future refactor of the rm patterns.
|
|
_QUOTED_BRACE_BYPASS = [
|
|
'rm -rf "/"',
|
|
"rm -rf '/'",
|
|
'rm -rf "/etc"',
|
|
'rm -rf "/home"',
|
|
'rm -rf "$HOME"',
|
|
"rm -rf ${HOME}",
|
|
'rm -rf "${HOME}"',
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("command", _QUOTED_BRACE_BYPASS)
|
|
def test_quoted_and_brace_paths_are_hardline_blocked(command):
|
|
"""Quoted paths and ${HOME} must hit the floor (was a silent bypass)."""
|
|
is_hl, desc = detect_hardline_command(command)
|
|
assert is_hl, f"quoting/brace bypass leaked through hardline floor: {command!r}"
|
|
assert desc
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Integration with the approval flow
|
|
# -------------------------------------------------------------------------
|
|
|
|
@pytest.fixture
|
|
def clean_session(monkeypatch):
|
|
"""Reset session-scoped approval state around each test."""
|
|
monkeypatch.delenv("HERMES_YOLO_MODE", raising=False)
|
|
monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
|
|
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
|
monkeypatch.delenv("HERMES_CRON_SESSION", raising=False)
|
|
monkeypatch.delenv("HERMES_EXEC_ASK", raising=False)
|
|
token = set_current_session_key("hardline_test")
|
|
try:
|
|
disable_session_yolo("hardline_test")
|
|
yield
|
|
finally:
|
|
disable_session_yolo("hardline_test")
|
|
reset_current_session_key(token)
|
|
|
|
|
|
def test_check_dangerous_command_blocks_hardline(clean_session):
|
|
result = check_dangerous_command("rm -rf /", "local")
|
|
assert result["approved"] is False
|
|
assert result.get("hardline") is True
|
|
assert "BLOCKED (hardline)" in result["message"]
|
|
|
|
|
|
def test_check_all_command_guards_blocks_hardline(clean_session):
|
|
result = check_all_command_guards("rm -rf /", "local")
|
|
assert result["approved"] is False
|
|
assert result.get("hardline") is True
|
|
assert "BLOCKED (hardline)" in result["message"]
|
|
|
|
|
|
def test_yolo_env_var_cannot_bypass_hardline(clean_session, monkeypatch):
|
|
"""HERMES_YOLO_MODE=1 must not bypass the hardline floor."""
|
|
monkeypatch.setenv("HERMES_YOLO_MODE", "1")
|
|
|
|
for cmd in ['rm -rf /', 'rm -rf "/"', 'rm -rf "$HOME"', "rm -rf ${HOME}",
|
|
"shutdown -h now", "mkfs.ext4 /dev/sda", "reboot"]:
|
|
r1 = check_dangerous_command(cmd, "local")
|
|
assert r1["approved"] is False, f"yolo leaked hardline on {cmd!r} (check_dangerous_command)"
|
|
assert r1.get("hardline") is True
|
|
|
|
r2 = check_all_command_guards(cmd, "local")
|
|
assert r2["approved"] is False, f"yolo leaked hardline on {cmd!r} (check_all_command_guards)"
|
|
assert r2.get("hardline") is True
|
|
|
|
|
|
def test_session_yolo_cannot_bypass_hardline(clean_session):
|
|
"""Gateway /yolo (session-scoped) must not bypass the hardline floor."""
|
|
enable_session_yolo("hardline_test")
|
|
|
|
result = check_dangerous_command("rm -rf /", "local")
|
|
assert result["approved"] is False
|
|
assert result.get("hardline") is True
|
|
|
|
result = check_all_command_guards("rm -rf /", "local")
|
|
assert result["approved"] is False
|
|
assert result.get("hardline") is True
|
|
|
|
|
|
def test_approvals_mode_off_cannot_bypass_hardline(clean_session, monkeypatch, tmp_path):
|
|
"""config approvals.mode=off (yolo-equivalent) must not bypass hardline."""
|
|
# _get_approval_mode() reads from hermes config; simplest path: monkeypatch the helper.
|
|
import tools.approval as approval_mod
|
|
monkeypatch.setattr(approval_mod, "_get_approval_mode", lambda: "off")
|
|
|
|
result = check_all_command_guards("rm -rf /", "local")
|
|
assert result["approved"] is False
|
|
assert result.get("hardline") is True
|
|
|
|
|
|
def test_cron_approve_mode_cannot_bypass_hardline(clean_session, monkeypatch):
|
|
"""Cron sessions with cron_mode=approve must not bypass hardline."""
|
|
monkeypatch.setenv("HERMES_CRON_SESSION", "1")
|
|
import tools.approval as approval_mod
|
|
monkeypatch.setattr(approval_mod, "_get_cron_approval_mode", lambda: "approve")
|
|
|
|
result = check_all_command_guards("rm -rf /", "local")
|
|
assert result["approved"] is False
|
|
assert result.get("hardline") is True
|
|
|
|
|
|
def test_container_backends_still_bypass(clean_session):
|
|
"""Containerized backends remain bypass-approved — they can't touch the host.
|
|
|
|
Hardline only protects environments with real host impact (local, ssh).
|
|
"""
|
|
for env in ("docker", "singularity", "modal", "daytona"):
|
|
r1 = check_dangerous_command("rm -rf /", env)
|
|
assert r1["approved"] is True, f"container {env} should still bypass"
|
|
r2 = check_all_command_guards("rm -rf /", env)
|
|
assert r2["approved"] is True, f"container {env} should still bypass"
|
|
|
|
|
|
def test_hardline_runs_before_dangerous_detection(clean_session):
|
|
"""Hardline command should return hardline block, not dangerous approval prompt."""
|
|
# `rm -rf /` is both hardline AND matches DANGEROUS_PATTERNS. Hardline must win.
|
|
is_dangerous, _, _ = detect_dangerous_command("rm -rf /")
|
|
assert is_dangerous, "precondition: rm -rf / is also in DANGEROUS_PATTERNS"
|
|
|
|
result = check_dangerous_command("rm -rf /", "local")
|
|
assert result.get("hardline") is True
|
|
|
|
|
|
def test_recoverable_dangerous_commands_still_pass_yolo(clean_session, monkeypatch):
|
|
"""Yolo still bypasses the regular DANGEROUS_PATTERNS list.
|
|
|
|
This confirms we haven't broken the yolo escape hatch — only narrowed it.
|
|
"""
|
|
monkeypatch.setenv("HERMES_YOLO_MODE", "1")
|
|
|
|
# These are dangerous but NOT hardline — yolo should still pass them.
|
|
for cmd in ["rm -rf /tmp/x", "chmod -R 777 .", "git reset --hard", "git push --force"]:
|
|
# Sanity: still flagged as dangerous
|
|
is_dangerous, _, _ = detect_dangerous_command(cmd)
|
|
assert is_dangerous, f"precondition: {cmd!r} should be in DANGEROUS_PATTERNS"
|
|
# But NOT hardline
|
|
is_hl, _ = detect_hardline_command(cmd)
|
|
assert not is_hl, f"{cmd!r} should not be hardline"
|
|
# And yolo bypasses the dangerous check
|
|
result = check_dangerous_command(cmd, "local")
|
|
assert result["approved"] is True, f"yolo should have bypassed {cmd!r}"
|
|
|
|
|
|
def test_hardline_list_is_small():
|
|
"""Hardline list stays focused on unrecoverable commands only.
|
|
|
|
If you're adding a 20th+ pattern, reconsider — it probably belongs in
|
|
DANGEROUS_PATTERNS where yolo can still bypass it.
|
|
"""
|
|
assert len(HARDLINE_PATTERNS) <= 20, (
|
|
f"HARDLINE_PATTERNS has grown to {len(HARDLINE_PATTERNS)} entries; "
|
|
"only truly unrecoverable commands belong here."
|
|
)
|
|
|
|
|
|
# =========================================================================
|
|
# Sudo stdin guard — blocks "sudo -S" without SUDO_PASSWORD
|
|
# =========================================================================
|
|
|
|
_SUDO_STDIN_BLOCK = [
|
|
"sudo -S whoami",
|
|
"echo hunter2 | sudo -S whoami",
|
|
"sudo -S -u root whoami",
|
|
"sudo -S apt-get install foo",
|
|
"echo password | sudo -S systemctl restart nginx",
|
|
"sudo -k && sudo -S whoami",
|
|
]
|
|
|
|
_SUDO_STDIN_ALLOW = [
|
|
# Plain sudo without -S — goes through normal approval
|
|
"sudo whoami",
|
|
"sudo apt-get update",
|
|
"sudo -u root whoami",
|
|
# -S flag not attached to sudo
|
|
"echo -S hello",
|
|
"some_tool -S thing",
|
|
# Literal text mention of sudo
|
|
"echo 'use sudo -S to pipe passwords'",
|
|
]
|
|
|
|
_SUDO_STDIN_BLOCK_YOLO = [
|
|
"sudo -S whoami",
|
|
"echo hunter2 | sudo -S apt-get install",
|
|
]
|
|
|
|
|
|
def test_sudo_stdin_guard_detects_without_password():
|
|
"""sudo -S is dangerous when SUDO_PASSWORD is not configured."""
|
|
import tools.approval as approval_mod
|
|
|
|
for cmd in _SUDO_STDIN_BLOCK:
|
|
is_blocked, desc = approval_mod._check_sudo_stdin_guard(cmd)
|
|
assert is_blocked, f"expected sudo stdin guard to block {cmd!r}"
|
|
assert "sudo" in desc.lower()
|
|
|
|
|
|
def test_sudo_stdin_guard_allows_benign_commands():
|
|
"""Commands without explicit sudo -S are not blocked."""
|
|
import tools.approval as approval_mod
|
|
|
|
for cmd in _SUDO_STDIN_ALLOW:
|
|
is_blocked, desc = approval_mod._check_sudo_stdin_guard(cmd)
|
|
assert not is_blocked, f"expected sudo stdin guard NOT to block {cmd!r}"
|
|
|
|
|
|
def test_sudo_stdin_guard_bypassed_when_password_configured(monkeypatch):
|
|
"""When SUDO_PASSWORD is set, sudo -S is legitimate (injected by transform)."""
|
|
import tools.approval as approval_mod
|
|
|
|
monkeypatch.setenv("SUDO_PASSWORD", "testpass")
|
|
for cmd in _SUDO_STDIN_BLOCK:
|
|
is_blocked, _ = approval_mod._check_sudo_stdin_guard(cmd)
|
|
assert not is_blocked, f"with SUDO_PASSWORD set, {cmd!r} should NOT be blocked"
|
|
|
|
|
|
def test_sudo_stdin_guard_blocks_via_check_all_command_guards(clean_session):
|
|
"""Integration: check_all_command_guards returns block for sudo -S."""
|
|
for cmd in _SUDO_STDIN_BLOCK:
|
|
result = check_all_command_guards(cmd, "local")
|
|
assert result["approved"] is False, f"expected block on {cmd!r}"
|
|
# Should NOT be marked as hardline (it's sudo-specific)
|
|
assert result.get("hardline") is not True
|
|
assert "BLOCKED" in result["message"]
|
|
assert "sudo -S" in result["message"].lower() or "sudo password" in result["message"].lower()
|
|
|
|
|
|
def test_sudo_stdin_guard_not_blocked_by_yolo(clean_session, monkeypatch):
|
|
"""yolo/approvals.mode=off must NOT bypass sudo stdin guard."""
|
|
monkeypatch.setenv("HERMES_YOLO_MODE", "1")
|
|
|
|
for cmd in _SUDO_STDIN_BLOCK_YOLO:
|
|
result = check_all_command_guards(cmd, "local")
|
|
assert result["approved"] is False, f"yolo leaked sudo guard on {cmd!r}"
|
|
|
|
|
|
def test_sudo_stdin_guard_container_bypass(clean_session):
|
|
"""Containerized backends still bypass — they can't touch the host."""
|
|
for env in ("docker", "singularity", "modal", "daytona"):
|
|
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}"
|