diff --git a/gateway/browser_plane.py b/gateway/browser_plane.py index cf1a68a4..69ee56cb 100644 --- a/gateway/browser_plane.py +++ b/gateway/browser_plane.py @@ -198,16 +198,19 @@ def _suffix_match(host: str, patterns: list[str]) -> bool: return False -def host_policy(host: str, allow: list[str], deny: list[str]) -> str | None: - """Why this host may not be reached, or None. Name rules only; the address rule follows.""" +def host_policy(host: str, allow: list[str], deny: list[str], private_ok: bool = False) -> str | None: + """Why this host may not be reached, or None. Name rules only; the address rule follows. + `private_ok` lifts the private-address names and literals: the operator's own browser sits on + the operator's own network (HR_BROWSER_CDP_URL), and a loopback address was still refused here + by name after the plane had opened that browser (hr-test, 2026-09-30).""" h = (host or "").strip().lower().rstrip(".") if not h: return "no host" if h.startswith("[") and h.endswith("]"): h = h[1:-1] - if _ip_private(h): + if not private_ok and _ip_private(h): return "a private or local address" - if h == "localhost" or any(h.endswith(s) for s in _PRIVATE_NAMES[1:]): + if not private_ok and (h == "localhost" or any(h.endswith(s) for s in _PRIVATE_NAMES[1:])): return "a private or local address" if deny and _suffix_match(h, deny): return "a site this agent is not allowed to visit" @@ -363,11 +366,12 @@ async def _allowed(s: Session, url: str) -> str | None: if u.scheme not in ("http", "https"): return f"a {u.scheme or 'non-web'} address" if u.scheme not in ("about", "data", "blob") else None host = u.hostname or "" - why = host_policy(host, s.allow, s.deny) + own = not s.vendor_id # the operator's own browser, on the operator's own network + why = host_policy(host, s.allow, s.deny, private_ok=own) if why: return why - if not s.vendor_id: - return None # the operator's own browser, on the operator's own network + if own: + return None if host not in s.host_cache: s.host_cache[host] = await resolves_private(host) return "a private or local address" if s.host_cache[host] else None diff --git a/gateway/tests/test_browser.py b/gateway/tests/test_browser.py index a1a00beb..98dd745b 100644 --- a/gateway/tests/test_browser.py +++ b/gateway/tests/test_browser.py @@ -914,9 +914,13 @@ def test_the_operators_own_browser_takes_the_vendors_place_whole(client, world, assert ven.created == [] and ven.calls == [] # the vendor was never asked own = browsers[-1] assert own.cdp == "ws://chrome.lan:9222/devtools/browser/own" and len(own.contexts) == 1 - # a private address is the operator's own network, not a page's trick - out = _call(client, tok1, "navigate", url="http://intranet.example/") - assert out["isError"] is False, out + # a private address is the operator's own network, not a page's trick: a resolved one, a + # loopback literal and a .local name alike (the name rules refused the last two first) + for url in ("http://intranet.example/", "http://127.0.0.1:3000/", "http://console.local/"): + out = _call(client, tok1, "navigate", url=url) + assert out["isError"] is False, (url, out) + assert browser_plane.host_policy("127.0.0.1", [], ["127.0.0.1"], private_ok=True) == "a site this agent is not allowed to visit" + assert browser_plane.host_policy("127.0.0.1", [], []) == "a private or local address" # the second session gets its own context on the same browser out = _call(client, tok2, "navigate", url="https://example.com/") assert out["isError"] is False and len(own.contexts) == 2