mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-02 02:01:47 +00:00
feat(approval): hardline blocklist for unrecoverable commands (#15878)
Adds a floor below --yolo: a tiny set of commands so catastrophic they should never run via the agent, regardless of --yolo, gateway /yolo, approvals.mode=off, or cron approve mode. Opting into yolo is trusting the agent with your files and services — not trusting it to wipe the disk or power the box off. The list is deliberately small (12 patterns), covering only unrecoverable ops: - rm -rf targeting /, /home, /etc, /usr, /var, /boot, /bin, /sbin, /lib, ~, $HOME - mkfs (any variant) - dd + redirection to raw block devices (/dev/sd*, /dev/nvme*, etc.) - fork bomb - kill -1 / kill -9 -1 - shutdown, reboot, halt, poweroff, init 0/6, telinit 0/6, systemctl poweroff/reboot/halt/kexec Recoverable-but-costly commands (git reset --hard, rm -rf /tmp/x, chmod -R 777, curl | sh) stay in DANGEROUS_PATTERNS where yolo can still pass them through — that's what yolo is for. Container backends (docker/singularity/modal/daytona) continue to bypass both hardline and dangerous checks, since nothing they do can touch the host. Inspired by Mercury Agent's permission-hardened blocklist.
This commit is contained in:
parent
a55de5bcd0
commit
eb28145f36
4 changed files with 427 additions and 13 deletions
290
tests/tools/test_hardline_blocklist.py
Normal file
290
tests/tools/test_hardline_blocklist.py
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
"""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 os
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.approval import (
|
||||
DANGEROUS_PATTERNS,
|
||||
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",
|
||||
# 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",
|
||||
# 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
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 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 /", "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."
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue