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:
teknium1
2026-06-01 03:29:48 -07:00
committed by Teknium
co-authored by sbw2025
parent 8f2931e3ee
commit 4e9d886d9d
2 changed files with 72 additions and 0 deletions
+51
View File
@@ -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."""