diff --git a/tests/tools/test_approval.py b/tests/tools/test_approval.py index 3863bc01d..fb1ef72cd 100644 --- a/tests/tools/test_approval.py +++ b/tests/tools/test_approval.py @@ -389,6 +389,57 @@ class TestTeePattern: assert key is None +class TestHermesConfigWriteProtection: + """Terminal-side pairing for the file_tools write_file/patch deny on + ~/.hermes/config.yaml (#14639). config.yaml IS the security policy + (approvals.mode/yolo live there, mtime-keyed cache reloads mid-session), + so a write_file deny without terminal-side coverage is unpaired theater. + These pin every terminal write idiom against the config file.""" + + def test_redirect_overwrite(self): + dangerous, key, desc = detect_dangerous_command("echo 'approvals:' > ~/.hermes/config.yaml") + assert dangerous is True + assert key is not None + + def test_append(self): + dangerous, key, desc = detect_dangerous_command("echo ' mode: off' >> ~/.hermes/config.yaml") + assert dangerous is True + + def test_tee(self): + dangerous, key, desc = detect_dangerous_command("echo x | tee ~/.hermes/config.yaml") + assert dangerous is True + + def test_cp_over_config(self): + dangerous, key, desc = detect_dangerous_command("cp /tmp/evil.yaml ~/.hermes/config.yaml") + assert dangerous is True + + def test_sed_in_place(self): + # The gap the pairing closes: sed -i mutates the file directly, + # bypassing the redirection/tee patterns. + dangerous, key, desc = detect_dangerous_command("sed -i 's/manual/off/' ~/.hermes/config.yaml") + assert dangerous is True + assert "hermes config" in desc.lower() or "in-place" in desc.lower() + + def test_sed_in_place_long_flag(self): + dangerous, key, desc = detect_dangerous_command("sed --in-place 's/manual/off/' ~/.hermes/config.yaml") + assert dangerous is True + + def test_custom_hermes_home(self): + dangerous, key, desc = detect_dangerous_command("echo x | tee $HERMES_HOME/config.yaml") + assert dangerous is True + + def test_read_is_safe(self): + # Reading config is not a write — must not trip. + dangerous, key, desc = detect_dangerous_command("cat ~/.hermes/config.yaml") + assert dangerous is False + + def test_normal_yaml_write_safe(self): + # A non-Hermes config.yaml in a project dir is handled by the project + # patterns, but a plain temp write must not false-positive. + dangerous, key, desc = detect_dangerous_command("echo data > /tmp/scratch.txt") + assert dangerous is False + + class TestFindExecFullPathRm: """Detect find -exec with full-path rm bypasses.""" diff --git a/tools/approval.py b/tools/approval.py index 1dbb6eb6e..47f4a5f44 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -128,6 +128,20 @@ _HERMES_ENV_PATH = ( r'(?:\$hermes_home|\$\{hermes_home\})/)' r'\.env\b' ) +# ~/.hermes/config.yaml IS the security policy: approvals.mode, yolo, and the +# permanent-approval allowlist live here, and the config cache is mtime-keyed +# so a write takes effect mid-session (the agent could flip approvals.mode=off +# and immediately bypass the gate). Pair the write_file/patch deny (file_tools +# _check_sensitive_path) with terminal-side coverage so `sed -i`, `tee`, `>`, +# `cp`, etc. targeting it are gated too — otherwise the deny is unpaired +# theater. Mirrors _HERMES_ENV_PATH; matches the HERMES_HOME override form as +# well as ~/.hermes/. +_HERMES_CONFIG_PATH = ( + r'(?:~\/\.hermes/|' + r'(?:\$home|\$\{home\})/\.hermes/|' + r'(?:\$hermes_home|\$\{hermes_home\})/)' + r'config\.yaml\b' +) _PROJECT_ENV_PATH = r'(?:(?:/|\.{1,2}/)?(?:[^\s/"\'`]+/)*\.env(?:\.[^/\s"\'`]+)*)' _PROJECT_CONFIG_PATH = r'(?:(?:/|\.{1,2}/)?(?:[^\s/"\'`]+/)*config\.yaml)' _SHELL_RC_FILES = ( @@ -153,6 +167,7 @@ _SENSITIVE_WRITE_TARGET = ( rf'(?:{_SYSTEM_CONFIG_PATH}|/dev/sd|' rf'{_SSH_SENSITIVE_PATH}|' rf'{_HERMES_ENV_PATH}|' + rf'{_HERMES_CONFIG_PATH}|' rf'{_SHELL_RC_FILES}|' rf'{_CREDENTIAL_FILES})' ) @@ -391,6 +406,12 @@ DANGEROUS_PATTERNS = [ (rf'\b(cp|mv|install)\b.*\s["\']?{_PROJECT_SENSITIVE_WRITE_TARGET}["\']?{_COMMAND_TAIL}', "overwrite project env/config file"), (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)"), + # In-place edit of a Hermes-managed security file (~/.hermes/config.yaml or + # .env). sed -i bypasses the redirection/tee patterns above because it + # mutates the file directly. Pairs the file_tools write_file/patch deny so + # the terminal side is not an open door. See #14639. + (rf'\bsed\s+-[^\s]*i.*(?:{_HERMES_CONFIG_PATH}|{_HERMES_ENV_PATH})', "in-place edit of Hermes config/env"), + (rf'\bsed\s+--in-place\b.*(?:{_HERMES_CONFIG_PATH}|{_HERMES_ENV_PATH})', "in-place edit of Hermes config/env (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"),