diff --git a/tests/tools/test_file_tools_live.py b/tests/tools/test_file_tools_live.py index 426b3543bb..72efbb2375 100644 --- a/tests/tools/test_file_tools_live.py +++ b/tests/tools/test_file_tools_live.py @@ -505,6 +505,25 @@ class TestExpandPath: assert result == str(Path.home()) _assert_clean(result) + def test_tilde_injection_blocked(self, ops): + """Paths like ~; rm -rf / must NOT execute shell commands.""" + malicious = "~; echo PWNED > /tmp/_hermes_injection_test" + result = ops._expand_path(malicious) + # The invalid username (contains ";") should prevent shell expansion. + # The path should be returned as-is (no expansion). + assert result == malicious + # Verify the injected command did NOT execute + import os + assert not os.path.exists("/tmp/_hermes_injection_test") + + def test_tilde_username_with_subpath(self, ops): + """~root/file.txt should attempt expansion (valid username).""" + result = ops._expand_path("~root/file.txt") + # On most systems ~root expands to /root + if result != "~root/file.txt": + assert result.endswith("/file.txt") + assert "~" not in result + # ── Terminal output cleanliness ────────────────────────────────────────── diff --git a/tools/file_operations.py b/tools/file_operations.py index 3f72c5fdb1..b3b8f15309 100644 --- a/tools/file_operations.py +++ b/tools/file_operations.py @@ -400,10 +400,16 @@ class ShellFileOperations(FileOperations): return home elif path.startswith('~/'): return home + path[1:] # Replace ~ with home - # ~username format - let shell expand it - expand_result = self._exec(f"echo {path}") - if expand_result.exit_code == 0: - return expand_result.stdout.strip() + # ~username format - extract and validate username before + # letting shell expand it (prevent shell injection via + # paths like "~; rm -rf /"). + rest = path[1:] # strip leading ~ + slash_idx = rest.find('/') + username = rest[:slash_idx] if slash_idx >= 0 else rest + if username and re.fullmatch(r'[a-zA-Z0-9._-]+', username): + expand_result = self._exec(f"echo {path}") + if expand_result.exit_code == 0 and expand_result.stdout.strip(): + return expand_result.stdout.strip() return path