mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
fix: scope private URL policy per profile
This commit is contained in:
parent
fa9217542d
commit
7b18de5f40
4 changed files with 111 additions and 16 deletions
|
|
@ -368,3 +368,33 @@ class TestAllowPrivateUrlsConfig:
|
|||
)
|
||||
|
||||
assert browser_tool._allow_private_urls() is False
|
||||
|
||||
def test_profile_scoped_config_does_not_reuse_another_profiles_opt_out(
|
||||
self, tmp_path
|
||||
):
|
||||
"""The browser's independent guard must follow the active profile."""
|
||||
from hermes_constants import (
|
||||
reset_hermes_home_override,
|
||||
set_hermes_home_override,
|
||||
)
|
||||
|
||||
allowed_home = tmp_path / "allowed"
|
||||
blocked_home = tmp_path / "blocked"
|
||||
allowed_home.mkdir()
|
||||
blocked_home.mkdir()
|
||||
(allowed_home / "config.yaml").write_text(
|
||||
"browser:\n allow_private_urls: true\n", encoding="utf-8"
|
||||
)
|
||||
(blocked_home / "config.yaml").write_text(
|
||||
"browser:\n allow_private_urls: false\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
def under_profile(home):
|
||||
token = set_hermes_home_override(home)
|
||||
try:
|
||||
return browser_tool._allow_private_urls()
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
|
||||
assert under_profile(allowed_home) is True
|
||||
assert under_profile(blocked_home) is False
|
||||
|
|
|
|||
|
|
@ -590,6 +590,42 @@ class TestGlobalAllowPrivateUrls:
|
|||
monkeypatch.setenv("HERMES_ALLOW_PRIVATE_URLS", "false")
|
||||
assert _global_allow_private_urls() is True
|
||||
|
||||
def test_profile_scoped_config_does_not_reuse_another_profiles_opt_out(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""Multiplexed profiles must resolve their own private-URL policy."""
|
||||
from hermes_constants import (
|
||||
reset_hermes_home_override,
|
||||
set_hermes_home_override,
|
||||
)
|
||||
|
||||
monkeypatch.delenv("HERMES_ALLOW_PRIVATE_URLS", raising=False)
|
||||
allowed_home = tmp_path / "allowed"
|
||||
blocked_home = tmp_path / "blocked"
|
||||
allowed_home.mkdir()
|
||||
blocked_home.mkdir()
|
||||
(allowed_home / "config.yaml").write_text(
|
||||
"security:\n allow_private_urls: true\n", encoding="utf-8"
|
||||
)
|
||||
(blocked_home / "config.yaml").write_text(
|
||||
"security:\n allow_private_urls: false\n", encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
socket,
|
||||
"getaddrinfo",
|
||||
lambda *_args, **_kwargs: [(2, 1, 6, "", ("10.0.0.8", 0))],
|
||||
)
|
||||
|
||||
def under_profile(home):
|
||||
token = set_hermes_home_override(home)
|
||||
try:
|
||||
return is_safe_url("http://profile-private.test/resource")
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
|
||||
assert under_profile(allowed_home) is True
|
||||
assert under_profile(blocked_home) is False
|
||||
|
||||
|
||||
class TestAllowPrivateUrlsIntegration:
|
||||
"""Integration tests: is_safe_url respects the global toggle."""
|
||||
|
|
|
|||
|
|
@ -66,7 +66,11 @@ from typing import Dict, Any, Optional, List, Tuple, Union
|
|||
from pathlib import Path
|
||||
from agent.auxiliary_client import call_llm
|
||||
from agent.redact import redact_cdp_url
|
||||
from hermes_constants import agent_browser_runnable, get_hermes_home
|
||||
from hermes_constants import (
|
||||
agent_browser_runnable,
|
||||
get_hermes_home,
|
||||
get_hermes_home_override,
|
||||
)
|
||||
from utils import env_int, is_truthy_value
|
||||
from hermes_cli.config import DEFAULT_CONFIG, cfg_get
|
||||
from hermes_cli._subprocess_compat import windows_hide_flags
|
||||
|
|
@ -1419,26 +1423,39 @@ def _last_session_key(task_id: str) -> str:
|
|||
def _allow_private_urls() -> bool:
|
||||
"""Return whether the browser is allowed to navigate to private/internal addresses.
|
||||
|
||||
Reads ``config["browser"]["allow_private_urls"]`` once and caches the result
|
||||
for the process lifetime. Defaults to ``False`` (SSRF protection active).
|
||||
Reads ``config["browser"]["allow_private_urls"]``. Single-profile calls
|
||||
cache the result for the process lifetime; multiplexed profile turns resolve
|
||||
their context-local config on each call. Defaults to ``False`` (SSRF
|
||||
protection active).
|
||||
"""
|
||||
global _cached_allow_private_urls, _allow_private_urls_resolved
|
||||
|
||||
# The profile multiplexer scopes config with a ContextVar while sharing
|
||||
# this module. Never reuse another profile's private-network opt-out.
|
||||
if get_hermes_home_override() is not None:
|
||||
return _resolve_allow_private_urls()
|
||||
|
||||
if _allow_private_urls_resolved:
|
||||
return _cached_allow_private_urls
|
||||
|
||||
_allow_private_urls_resolved = True
|
||||
_cached_allow_private_urls = False # safe default
|
||||
_cached_allow_private_urls = _resolve_allow_private_urls()
|
||||
return _cached_allow_private_urls
|
||||
|
||||
|
||||
def _resolve_allow_private_urls() -> bool:
|
||||
"""Read the browser private-URL toggle from the active config scope."""
|
||||
try:
|
||||
from hermes_cli.config import read_raw_config
|
||||
cfg = read_raw_config()
|
||||
browser_cfg = cfg.get("browser", {})
|
||||
if isinstance(browser_cfg, dict):
|
||||
_cached_allow_private_urls = is_truthy_value(
|
||||
return is_truthy_value(
|
||||
browser_cfg.get("allow_private_urls"), default=False
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Could not read allow_private_urls from config: %s", e)
|
||||
return _cached_allow_private_urls
|
||||
return False
|
||||
|
||||
|
||||
def _socket_safe_tmpdir() -> str:
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import re
|
|||
from typing import Any, Optional
|
||||
from urllib.parse import parse_qsl, quote, unquote, urljoin, urlparse, urlsplit, urlunsplit
|
||||
|
||||
from hermes_constants import get_hermes_home_override
|
||||
from utils import is_truthy_value
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -224,23 +225,36 @@ def _global_allow_private_urls() -> bool:
|
|||
2. ``security.allow_private_urls`` in config.yaml
|
||||
3. ``browser.allow_private_urls`` in config.yaml (legacy / backward compat)
|
||||
|
||||
Result is cached for the process lifetime.
|
||||
The single-profile result is cached for the process lifetime. Multiplexed
|
||||
profile turns bypass that process-global cache because their config root is
|
||||
context-local; ``read_raw_config()`` already provides path/mtime caching.
|
||||
"""
|
||||
global _allow_private_resolved, _cached_allow_private
|
||||
|
||||
# A multiplex gateway serves several independently configured profiles in
|
||||
# one process. Reusing the first profile's opt-out here would let it disable
|
||||
# private-network blocking for every later profile in that process.
|
||||
if get_hermes_home_override() is not None:
|
||||
return _resolve_allow_private_urls()
|
||||
|
||||
if _allow_private_resolved:
|
||||
return _cached_allow_private
|
||||
|
||||
_allow_private_resolved = True
|
||||
_cached_allow_private = False # safe default
|
||||
_cached_allow_private = _resolve_allow_private_urls()
|
||||
return _cached_allow_private
|
||||
|
||||
|
||||
def _resolve_allow_private_urls() -> bool:
|
||||
"""Resolve the effective private-URL toggle from the active config scope."""
|
||||
|
||||
# 1. Env var override (highest priority)
|
||||
env_val = os.getenv("HERMES_ALLOW_PRIVATE_URLS", "").strip().lower()
|
||||
if env_val in {"true", "1", "yes"}:
|
||||
_cached_allow_private = True
|
||||
return _cached_allow_private
|
||||
return True
|
||||
if env_val in {"false", "0", "no"}:
|
||||
# Explicit false — don't fall through to config
|
||||
return _cached_allow_private
|
||||
return False
|
||||
|
||||
# 2. Config file
|
||||
try:
|
||||
|
|
@ -251,20 +265,18 @@ def _global_allow_private_urls() -> bool:
|
|||
if isinstance(sec, dict) and is_truthy_value(
|
||||
sec.get("allow_private_urls"), default=False
|
||||
):
|
||||
_cached_allow_private = True
|
||||
return _cached_allow_private
|
||||
return True
|
||||
# browser.allow_private_urls (legacy fallback)
|
||||
browser = cfg.get("browser", {})
|
||||
if isinstance(browser, dict) and is_truthy_value(
|
||||
browser.get("allow_private_urls"), default=False
|
||||
):
|
||||
_cached_allow_private = True
|
||||
return _cached_allow_private
|
||||
return True
|
||||
except Exception:
|
||||
# Config unavailable (e.g. tests, early import) — keep default
|
||||
pass
|
||||
|
||||
return _cached_allow_private
|
||||
return False
|
||||
|
||||
|
||||
def _reset_allow_private_cache() -> None:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue