From a9055f91a43810219ea39d618da42716d76e12d2 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 17 Apr 2026 17:10:26 -0700 Subject: [PATCH] Inspired by Claude Code: tighten dangerous-command detection Port three hardening patches from Claude Code 2.1.113's expanded deny rules to hermes' detect_dangerous_command() pattern list. 1. macOS /private/{etc,var,tmp,home} system paths /etc, /var, /tmp, /home are symlinks to /private/ on macOS. A write to /private/etc/sudoers works identically to /etc/sudoers but bypassed the plain /etc/ pattern check. Extracted a shared _SYSTEM_CONFIG_PATH fragment so /etc/ and the /private/ mirror stay in sync across redirect / tee / cp / mv / install / sed -i patterns. 2. killall -9 / -KILL / -SIGKILL / -s KILL / -r Parallel to the existing pkill -9 pattern. killall -9 against non-hermes processes was previously unprotected, and killall -r can sweep unrelated processes matching a regex. 3. find -execdir rm Same destructive effect as find -exec rm but ran in each match's directory. The previous pattern required a literal '-exec ' so -execdir slipped through. Guarded by 32 new test cases in 4 test classes: - TestMacOSPrivateSystemPaths (11 cases) - TestKillallKillSignals (9 cases) - TestFindExecdir (4 cases) - TestEtcPatternsUnaffectedByRefactor (6 regression guards on the existing /etc/ coverage after the _SYSTEM_CONFIG_PATH refactor) Inspiration: https://github.com/anthropics/claude-code/releases (Claude Code 2.1.113, April 17 2026 - "Enhanced deny rules" and "Dangerous path protection") --- tests/tools/test_approval.py | 203 +++++++++++++++++++++++++++++++++++ tools/approval.py | 37 +++++-- 2 files changed, 233 insertions(+), 7 deletions(-) diff --git a/tests/tools/test_approval.py b/tests/tools/test_approval.py index 661b86bf3..bfd1f8914 100644 --- a/tests/tools/test_approval.py +++ b/tests/tools/test_approval.py @@ -821,3 +821,206 @@ class TestChmodExecuteCombo: assert dangerous is False +class TestMacOSPrivateSystemPaths: + """Inspired by Claude Code 2.1.113 "dangerous path protection". + + On macOS, /etc, /var, /tmp, /home are symlinks to + /private/{etc,var,tmp,home}. A command that writes to + /private/etc/sudoers works identically to /etc/sudoers but bypasses + a plain "/etc/" pattern check. These tests guard the shared + _SYSTEM_CONFIG_PATH fragment used across redirect / tee / cp / mv / + install / sed -i patterns. + """ + + def test_private_etc_redirect(self): + dangerous, _, desc = detect_dangerous_command( + "echo 'root ALL=NOPASSWD: ALL' > /private/etc/sudoers" + ) + assert dangerous is True + assert "system config" in desc.lower() + + def test_private_var_redirect(self): + dangerous, _, _ = detect_dangerous_command( + "echo payload > /private/var/db/dslocal/nodes/x" + ) + assert dangerous is True + + def test_private_etc_via_tee(self): + dangerous, _, desc = detect_dangerous_command( + "echo malicious | tee /private/etc/hosts" + ) + assert dangerous is True + assert "tee" in desc.lower() or "system" in desc.lower() + + def test_private_etc_cp(self): + dangerous, _, desc = detect_dangerous_command( + "cp malicious.conf /private/etc/hosts" + ) + assert dangerous is True + assert "copy" in desc.lower() or "system config" in desc.lower() + + def test_private_etc_mv(self): + dangerous, _, _ = detect_dangerous_command( + "mv evil /private/etc/ssh/sshd_config" + ) + assert dangerous is True + + def test_private_etc_install(self): + dangerous, _, _ = detect_dangerous_command( + "install -m 600 key /private/etc/ssh/keys" + ) + assert dangerous is True + + def test_private_etc_sed_in_place(self): + dangerous, _, desc = detect_dangerous_command( + "sed -i 's/root/pwned/' /private/etc/passwd" + ) + assert dangerous is True + assert "in-place" in desc.lower() or "system config" in desc.lower() + + def test_private_var_sed_long_flag(self): + dangerous, _, _ = detect_dangerous_command( + "sed --in-place 's/x/y/' /private/var/log/wtmp" + ) + assert dangerous is True + + def test_private_tmp_cp(self): + dangerous, _, _ = detect_dangerous_command( + "cp rootkit /private/tmp/payload" + ) + assert dangerous is True + + def test_ls_private_is_safe(self): + """Reading under /private/ must not trigger approval.""" + dangerous, _, _ = detect_dangerous_command("ls /private") + assert dangerous is False + + def test_echo_mentioning_private_path_is_safe(self): + """Literal mention of /private/etc in an echo string must not fire.""" + dangerous, _, _ = detect_dangerous_command( + "echo 'the macOS path is /private/etc on disk'" + ) + assert dangerous is False + + +class TestKillallKillSignals: + """Inspired by Claude Code 2.1.113 expanded deny rules. + + The existing pattern caught `pkill -9` but not the equivalent + `killall -9` / `-KILL` / `-s KILL` / `-r ` broad sweeps that + can wipe out unrelated processes. + """ + + def test_killall_dash_9(self): + dangerous, _, desc = detect_dangerous_command("killall -9 firefox") + assert dangerous is True + assert "kill" in desc.lower() + + def test_killall_dash_kill(self): + dangerous, _, _ = detect_dangerous_command("killall -KILL firefox") + assert dangerous is True + + def test_killall_dash_sigkill(self): + dangerous, _, _ = detect_dangerous_command("killall -SIGKILL firefox") + assert dangerous is True + + def test_killall_dash_s_kill(self): + dangerous, _, _ = detect_dangerous_command("killall -s KILL firefox") + assert dangerous is True + + def test_killall_dash_s_signum(self): + dangerous, _, _ = detect_dangerous_command("killall -s 9 firefox") + assert dangerous is True + + def test_killall_regex(self): + """killall -r is a broad sweep; require approval.""" + dangerous, _, desc = detect_dangerous_command("killall -r 'fire.*'") + assert dangerous is True + assert "regex" in desc.lower() or "kill" in desc.lower() + + def test_killall_combined_flags(self): + dangerous, _, _ = detect_dangerous_command("killall -9 -r 'herm.*'") + assert dangerous is True + + def test_killall_list_signals_is_safe(self): + """`killall -l` lists signals and is harmless — must not fire.""" + dangerous, _, _ = detect_dangerous_command("killall -l") + assert dangerous is False + + def test_killall_version_is_safe(self): + dangerous, _, _ = detect_dangerous_command("killall -V") + assert dangerous is False + + +class TestFindExecdir: + """Inspired by Claude Code 2.1.113 tightening of find rules. + + `find -execdir rm` has the same destructive effect as `find -exec rm` + but ran in each match's directory. Previously missed because the + pattern required a literal `-exec ` followed by a space. + """ + + def test_find_execdir_rm(self): + dangerous, _, desc = detect_dangerous_command( + "find . -execdir rm {} \\;" + ) + assert dangerous is True + assert "find" in desc.lower() or "rm" in desc.lower() + + def test_find_execdir_with_absolute_rm(self): + dangerous, _, _ = detect_dangerous_command( + "find /var -execdir /bin/rm -rf {} \\;" + ) + assert dangerous is True + + def test_find_exec_rm_still_caught(self): + """Original -exec pattern must still fire (regression guard).""" + dangerous, _, _ = detect_dangerous_command( + "find . -exec rm {} \\;" + ) + assert dangerous is True + + def test_find_execdir_ls_is_safe(self): + """-execdir with a read-only command is not dangerous.""" + dangerous, _, _ = detect_dangerous_command( + "find . -execdir ls {} \\;" + ) + assert dangerous is False + + +class TestEtcPatternsUnaffectedByRefactor: + """Regression guard: the /etc/ patterns were refactored to share the + _SYSTEM_CONFIG_PATH fragment with the /private/ mirror. Make sure the + existing /etc/ coverage remains identical. + """ + + def test_etc_redirect(self): + dangerous, _, _ = detect_dangerous_command("echo x > /etc/hosts") + assert dangerous is True + + def test_etc_cp(self): + dangerous, _, _ = detect_dangerous_command("cp evil /etc/hosts") + assert dangerous is True + + def test_etc_sed_inline(self): + dangerous, _, _ = detect_dangerous_command( + "sed -i 's/a/b/' /etc/hosts" + ) + assert dangerous is True + + def test_etc_tee(self): + dangerous, _, _ = detect_dangerous_command( + "echo x | tee /etc/hosts" + ) + assert dangerous is True + + def test_cat_etc_hostname_is_safe(self): + """Reading /etc/ files is safe — only writes require approval.""" + dangerous, _, _ = detect_dangerous_command("cat /etc/hostname") + assert dangerous is False + + def test_grep_etc_passwd_is_safe(self): + dangerous, _, _ = detect_dangerous_command("grep root /etc/passwd") + assert dangerous is False + + diff --git a/tools/approval.py b/tools/approval.py index d9fcf51a8..ab1feabe5 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -63,8 +63,19 @@ _HERMES_ENV_PATH = ( r'(?:\$hermes_home|\$\{hermes_home\})/)' r'\.env\b' ) +# macOS: /etc, /var, /tmp, /home are symlinks to /private/{etc,var,tmp,home}. +# A command written to target /private/etc/sudoers works identically to +# /etc/sudoers on macOS but bypasses a plain "/etc/" pattern check. Match +# both forms. Inspired by Claude Code 2.1.113's "dangerous path protection". +_MACOS_PRIVATE_SYSTEM_PATH = r'/private/(?:etc|var|tmp|home)/' +# System-config paths that should trigger approval for any write/edit, +# collapsing /etc, its macOS /private/etc mirror, and /etc/sudoers.d/ into +# one shared fragment so new DANGEROUS_PATTERNS stay consistent. +_SYSTEM_CONFIG_PATH = ( + rf'(?:/etc/|{_MACOS_PRIVATE_SYSTEM_PATH})' +) _SENSITIVE_WRITE_TARGET = ( - r'(?:/etc/|/dev/sd|' + rf'(?:{_SYSTEM_CONFIG_PATH}|/dev/sd|' rf'{_SSH_SENSITIVE_PATH}|' rf'{_HERMES_ENV_PATH})' ) @@ -87,10 +98,17 @@ DANGEROUS_PATTERNS = [ (r'\bDROP\s+(TABLE|DATABASE)\b', "SQL DROP"), (r'\bDELETE\s+FROM\b(?!.*\bWHERE\b)', "SQL DELETE without WHERE"), (r'\bTRUNCATE\s+(TABLE)?\s*\w', "SQL TRUNCATE"), - (r'>\s*/etc/', "overwrite system config"), + (rf'>\s*{_SYSTEM_CONFIG_PATH}', "overwrite system config"), (r'\bsystemctl\s+(-[^\s]+\s+)*(stop|restart|disable|mask)\b', "stop/restart system service"), (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 / + # -s KILL / -SIGKILL forms, and also `killall -r ` broad sweeps + # that can wipe out unrelated processes by accident. + # Inspired by Claude Code 2.1.113 expanded deny rules. + (r'\bkillall\s+(-[^\s]*\s+)*-(9|KILL|SIGKILL)\b', "force kill processes (killall -KILL)"), + (r'\bkillall\s+(-[^\s]*\s+)*-s\s+(KILL|SIGKILL|9)\b', "force kill processes (killall -s KILL)"), + (r'\bkillall\s+(-[^\s]*\s+)*-r\b', "kill processes by regex (killall -r)"), (r':\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:', "fork bomb"), # Any shell invocation via -c or combined flags like -lc, -ic, etc. (r'\b(bash|sh|zsh|ksh)\s+-[^\s]*c(\s+|$)', "shell command via -c/-lc flag"), @@ -100,7 +118,11 @@ DANGEROUS_PATTERNS = [ (rf'\btee\b.*["\']?{_SENSITIVE_WRITE_TARGET}', "overwrite system file via tee"), (rf'>>?\s*["\']?{_SENSITIVE_WRITE_TARGET}', "overwrite system file via redirection"), (r'\bxargs\s+.*\brm\b', "xargs with rm"), - (r'\bfind\b.*-exec\s+(/\S*/)?rm\b', "find -exec rm"), + # find -exec rm / -execdir rm — the -execdir variant (same semantics, + # runs in the directory of each match) was previously missed. Claude + # Code 2.1.113 tightened their equivalent find rule to stop auto- + # approving -exec / -delete flags. + (r'\bfind\b.*-exec(?:dir)?\s+(/\S*/)?rm\b', "find -exec/-execdir rm"), (r'\bfind\b.*-delete\b', "find -delete"), # Gateway lifecycle protection: prevent the agent from killing its own # gateway process. These commands trigger a gateway restart/stop that @@ -118,10 +140,11 @@ DANGEROUS_PATTERNS = [ # to regex at detection time. Catch the structural pattern instead. (r'\bkill\b.*\$\(\s*pgrep\b', "kill process via pgrep expansion (self-termination)"), (r'\bkill\b.*`\s*pgrep\b', "kill process via backtick pgrep expansion (self-termination)"), - # File copy/move/edit into sensitive system paths - (r'\b(cp|mv|install)\b.*\s/etc/', "copy/move file into /etc/"), - (r'\bsed\s+-[^\s]*i.*\s/etc/', "in-place edit of system config"), - (r'\bsed\s+--in-place\b.*\s/etc/', "in-place edit of system config (long flag)"), + # File copy/move/edit into sensitive system paths (/etc/ and macOS + # /private/etc/ mirror). + (rf'\b(cp|mv|install)\b.*\s{_SYSTEM_CONFIG_PATH}', "copy/move file into system config path"), + (rf'\bsed\s+-[^\s]*i.*\s{_SYSTEM_CONFIG_PATH}', "in-place edit of system config"), + (rf'\bsed\s+--in-place\b.*\s{_SYSTEM_CONFIG_PATH}', "in-place edit of system config (long flag)"), # Script execution via heredoc — bypasses the -e/-c flag patterns above. # `python3 << 'EOF'` feeds arbitrary code via stdin without -c/-e flags. (r'\b(python[23]?|perl|ruby|node)\s+<<', "script execution via heredoc"),