diff --git a/agent/verification_stop.py b/agent/verification_stop.py index 605d58d3a7de..1f68b1aace55 100644 --- a/agent/verification_stop.py +++ b/agent/verification_stop.py @@ -289,7 +289,7 @@ def build_verify_on_stop_nudge( + "), read any failure, repair the code, and summarize what passed." ) else: - temp_dir = tempfile.gettempdir() + temp_dir = os.path.realpath(tempfile.gettempdir()) command_instruction = ( "No canonical test/lint/build command was detected. Create a focused " f"temporary verification script under `{temp_dir}` using an OS-safe " diff --git a/tests/agent/test_verification_stop.py b/tests/agent/test_verification_stop.py index 7beaf4a1ea7b..61a8c37fcf68 100644 --- a/tests/agent/test_verification_stop.py +++ b/tests/agent/test_verification_stop.py @@ -260,6 +260,27 @@ def test_no_suite_nudge_requests_temp_script(tmp_path, monkeypatch): assert "creative UI/visual work" in nudge +def test_no_suite_nudge_uses_canonical_temp_dir(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + project = tmp_path / "project" + project.mkdir() + (project / "package.json").write_text("{}", encoding="utf-8") + real_temp = tmp_path / "real-temp" + real_temp.mkdir() + linked_temp = tmp_path / "linked-temp" + linked_temp.symlink_to(real_temp, target_is_directory=True) + monkeypatch.setattr(tempfile, "gettempdir", lambda: str(linked_temp)) + + nudge = build_verify_on_stop_nudge( + session_id="s1", + changed_paths=[str(project / "src" / "app.ts")], + ) + + assert nudge is not None + assert str(real_temp) in nudge + assert str(linked_temp) not in nudge + + def test_verify_guidance_can_be_disabled(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) _node_project(tmp_path) diff --git a/tests/tools/test_approval.py b/tests/tools/test_approval.py index 434fa9af2cb8..e65a73333434 100644 --- a/tests/tools/test_approval.py +++ b/tests/tools/test_approval.py @@ -2,6 +2,7 @@ import ast import os +import tempfile import threading import time from pathlib import Path @@ -116,6 +117,53 @@ class TestDetectDangerousRm: assert key is not None assert "delete" in desc.lower() + def test_nonrecursive_verification_artifact_cleanup_is_not_dangerous(self): + with mock_patch("tempfile.gettempdir", return_value="/tmp"): + for prefix in ("hermes-verify-", "hermes-ad-hoc-"): + assert detect_dangerous_command(f"rm -f /tmp/{prefix}example.py") == ( + False, + None, + None, + ) + + def test_symlinked_temp_dir_only_exempts_canonical_target(self, tmp_path): + real_temp = tmp_path / "real-temp" + real_temp.mkdir() + linked_temp = tmp_path / "linked-temp" + linked_temp.symlink_to(real_temp, target_is_directory=True) + basename = "hermes-verify-example.py" + + with mock_patch("tempfile.gettempdir", return_value=str(linked_temp)): + assert detect_dangerous_command(f"rm -f {linked_temp / basename}")[0] is True + assert detect_dangerous_command(f"rm -f {real_temp / basename}") == ( + False, + None, + None, + ) + + def test_verification_cleanup_exemption_rejects_broader_deletions(self): + commands = ( + "rm -rf /tmp/hermes-verify-example.py", + "rm -f /tmp/hermes-verify-example.py /tmp/other.py", + "rm -f /tmp/nested/hermes-verify-example.py", + "rm -f /tmp/nested/../hermes-verify-example.py", + "rm -f /tmp/./hermes-verify-example.py", + "rm -f /tmp//hermes-verify-example.py", + "rm -f /tmp/a/../../tmp/hermes-verify-example.py", + "rm -f /var/tmp/hermes-verify-example.py", + "rm -f /tmp/unrelated.py", + "rm -f /tmp/hermes-verify-*", + "rm -f /tmp/hermes-verify-$(touch>/tmp/pwned).py", + "rm -f /tmp/hermes-ad-hoc-`touch>/tmp/pwned`.py", + "rm -f /tmp/hermes-verify-example.py; touch /tmp/pwned", + ) + with mock_patch("tempfile.gettempdir", return_value="/tmp"): + for command in commands: + is_dangerous, key, desc = detect_dangerous_command(command) + assert is_dangerous is True, command + assert key is not None, command + assert "delete" in desc.lower(), command + class TestWindowsShellDestructiveCommands: def test_cmd_del_requires_approval(self): diff --git a/tools/approval.py b/tools/approval.py index 365b63da521c..836066eda982 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -17,6 +17,7 @@ import os import re import shlex import sys +import tempfile import threading import time import unicodedata @@ -1383,12 +1384,36 @@ def _command_detection_variants(command: str): yield variant +def _is_verification_artifact_cleanup(command: str) -> bool: + """Return whether *command* only removes one Hermes ad-hoc temp script.""" + try: + argv = shlex.split(command, posix=True) + except ValueError: + return False + if len(argv) != 3 or argv[0] != "rm" or argv[1] != "-f": + return False + + operand = argv[2] + temp_dir = os.path.realpath(tempfile.gettempdir()) + basename = os.path.basename(operand) + if operand != os.path.join(temp_dir, basename): + return False + + target = os.path.realpath(operand) + if os.path.dirname(target) != temp_dir: + return False + return re.fullmatch(r"hermes-(?:verify|ad-hoc)-[A-Za-z0-9_.-]+", basename) is not None + + def detect_dangerous_command(command: str) -> tuple: """Check if a command matches any dangerous patterns. Returns: (is_dangerous, pattern_key, description) or (False, None, None) """ + if _is_verification_artifact_cleanup(command): + return (False, None, None) + for command_variant in _command_detection_variants(command): command_lower = command_variant.lower() for pattern_re, description in DANGEROUS_PATTERNS_COMPILED: