Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions tests/tools/test_yolo_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand All @@ -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 = [
Expand All @@ -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}
Expand Down
19 changes: 17 additions & 2 deletions tools/approval.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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"),
]


Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand Down
29 changes: 29 additions & 0 deletions tools/environments/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,13 +124,40 @@ 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)


_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.

Expand Down Expand Up @@ -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


Expand Down
Loading