fix(approval): pair terminal-side gate for ~/.hermes/config.yaml writes
Subway2023's #14639 blocks write_file/patch to ~/.hermes/config.yaml, but the terminal side was only partially paired: echo>/tee/cp/mv to config.yaml already tripped the project-config pattern, while `sed -i` and direct edits slipped through with auto-approve. An unpaired write_file deny is theater per SECURITY.md — the agent could flip approvals.mode=off via `sed -i` and the mtime-keyed config cache reloads it mid-session. config.yaml IS the security policy (approvals.mode/yolo/permanent allowlist live there), so it warrants real pairing, not a half-door. Add a _HERMES_CONFIG_PATH fragment mirroring _HERMES_ENV_PATH, fold it into _SENSITIVE_WRITE_TARGET (covers tee/>/>>/cp/mv), and add sed -i coverage for both config.yaml and .env. Pins 9 regression tests including no-regression guards (reads pass, /tmp writes pass). Co-authored-by: sbw2025 <subw3@mail2.sysu.edu.cn>
This commit is contained in:
@ -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."""
|
||||
|
||||
|
||||
@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user