diff --git a/tests/tools/test_yolo_mode.py b/tests/tools/test_yolo_mode.py index 7d30adcc6c88..e6ad9260d0f8 100644 --- a/tests/tools/test_yolo_mode.py +++ b/tests/tools/test_yolo_mode.py @@ -48,6 +48,7 @@ def test_dangerous_command_blocked_normally(self, monkeypatch): def test_dangerous_command_approved_in_yolo_mode(self, monkeypatch): """With HERMES_YOLO_MODE, dangerous commands are auto-approved.""" monkeypatch.setenv("HERMES_YOLO_MODE", "1") + monkeypatch.setattr("tools.approval._YOLO_AT_STARTUP", True) monkeypatch.setenv("HERMES_INTERACTIVE", "1") monkeypatch.setenv("HERMES_SESSION_KEY", "test-session") @@ -58,6 +59,7 @@ def test_dangerous_command_approved_in_yolo_mode(self, monkeypatch): def test_yolo_mode_works_for_all_patterns(self, monkeypatch): """Yolo mode bypasses all dangerous patterns, not just some.""" monkeypatch.setenv("HERMES_YOLO_MODE", "1") + monkeypatch.setattr("tools.approval._YOLO_AT_STARTUP", True) monkeypatch.setenv("HERMES_INTERACTIVE", "1") dangerous_commands = [ @@ -76,6 +78,7 @@ def test_yolo_mode_works_for_all_patterns(self, monkeypatch): def test_combined_guard_bypasses_yolo_mode(self, monkeypatch): """The new combined guard should preserve yolo bypass semantics.""" monkeypatch.setenv("HERMES_YOLO_MODE", "1") + monkeypatch.setattr("tools.approval._YOLO_AT_STARTUP", True) monkeypatch.setenv("HERMES_INTERACTIVE", "1") called = {"value": False} diff --git a/tools/approval.py b/tools/approval.py index 95011173fdc4..67716e4e60ea 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -37,6 +37,10 @@ # Dangerous command patterns # ========================================================================= +# Snapshot YOLO mode at import time so subprocesses cannot export it +# to bypass approval in later commands within the same session. +_YOLO_AT_STARTUP = bool(os.getenv("HERMES_YOLO_MODE")) + DANGEROUS_PATTERNS = [ (r'\brm\s+(-[^\s]*\s+)*/', "delete in root path"), (r'\brm\s+-[^\s]*r', "recursive delete"), @@ -75,6 +79,15 @@ (r'\b(cp|mv|install)\b.*\s/etc/', "copy/move file into /etc/"), (r'\bsed\s+-[^\s]*i.*\s/etc/', "in-place edit of system config"), (r'\bsed\s+--in-place\b.*\s/etc/', "in-place edit of system config (long flag)"), + # Data exfiltration vectors + (r'\b(nc|ncat|socat)\b.*\d+\.\d+\.\d+\.\d+', "netcat to external IP"), + (r'/dev/tcp/', "bash /dev/tcp exfiltration"), + (r'/dev/udp/', "bash /dev/udp exfiltration"), + (r'\bopenssl\s+s_client\s+-connect\b', "openssl outbound connection"), + (r'\bcurl\b.*(-d|--data|--data-binary|--data-urlencode|--upload-file)\b', "curl with data upload"), + (r'\bwget\b.*(--post-data|--post-file)\b', "wget with data upload"), + # Credential file reads via common tools + (r'\b(cat|head|tail|less|more|bat)\b.*(\.ssh/|aws/credentials|\.env|\.netrc|\.gnupg/|\.kube/config)', "read credential file"), ] @@ -434,7 +447,9 @@ def check_dangerous_command(command: str, env_type: str, return {"approved": True, "message": None} # --yolo: bypass all approval prompts - if os.getenv("HERMES_YOLO_MODE"): + # Read from _YOLO_AT_STARTUP to prevent subprocesses from exporting + # this var to bypass approval in subsequent commands. + if _YOLO_AT_STARTUP: return {"approved": True, "message": None} is_dangerous, pattern_key, description = detect_dangerous_command(command) @@ -536,7 +551,7 @@ def check_all_command_guards(command: str, env_type: str, # --yolo or approvals.mode=off: bypass all approval prompts approval_mode = _get_approval_mode() - if os.getenv("HERMES_YOLO_MODE") or approval_mode == "off": + if _YOLO_AT_STARTUP or approval_mode == "off": return {"approved": True, "message": None} is_cli = os.getenv("HERMES_INTERACTIVE") diff --git a/tools/environments/local.py b/tools/environments/local.py index 27282b6ef67a..cfb7503b4dd3 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -124,6 +124,23 @@ def _build_provider_env_blocklist() -> frozenset: "MODAL_TOKEN_ID", "MODAL_TOKEN_SECRET", "DAYTONA_API_KEY", + # Cloud provider credentials — prevent agent subprocesses from + # accessing the user's cloud accounts. + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_SECURITY_TOKEN", + "AZURE_CLIENT_SECRET", + "AZURE_CLIENT_ID", + "AZURE_TENANT_ID", + "GOOGLE_APPLICATION_CREDENTIALS", + "KUBECONFIG", + "DOCKER_HOST", + "DOCKER_CERT_PATH", + "NPM_TOKEN", + "PYPI_TOKEN", + "SSH_AUTH_SOCK", + "GPG_AGENT_INFO", }) return frozenset(blocked) @@ -131,6 +148,16 @@ def _build_provider_env_blocklist() -> frozenset: _HERMES_PROVIDER_ENV_BLOCKLIST = _build_provider_env_blocklist() +# Git hardening: disable hooks and credential prompts in all +# subprocesses to prevent malicious repos from executing code via +# .git/hooks/ or prompting for credentials on clone. +# NOTE: We do NOT set GIT_CONFIG_GLOBAL=/dev/null because that +# disables credential helpers, aliases, and all user git config. +_GIT_HARDENING_VARS = { + "GIT_TERMINAL_PROMPT": "0", +} + + def _sanitize_subprocess_env(base_env: dict | None, extra_env: dict | None = None) -> dict: """Filter Hermes-managed secrets from a subprocess environment. @@ -159,6 +186,8 @@ def _sanitize_subprocess_env(base_env: dict | None, extra_env: dict | None = Non elif key not in _HERMES_PROVIDER_ENV_BLOCKLIST or _is_passthrough(key): sanitized[key] = value + # Apply git hardening + sanitized.update(_GIT_HARDENING_VARS) return sanitized