diff --git a/agent/file_safety.py b/agent/file_safety.py index 7547000fa4..fb469833dc 100644 --- a/agent/file_safety.py +++ b/agent/file_safety.py @@ -374,6 +374,34 @@ def get_read_block_error(path: str) -> Optional[str]: "security boundary; the terminal tool can still bypass.)" ) + # browser-profile/: real-profile browsing snapshot (browser.use_real_profile). + # A copy of the user's Cookies / Login Data / Web Data lives here — the same + # credential class as auth.json, so it gets the same directory-prefix read + # deny. Prefix (not a finite filename list) so future Chromium files are + # covered too. + for hd in hermes_dirs: + try: + browser_profile = (hd / "browser-profile").resolve() + except Exception: + continue + if resolved == browser_profile: + return ( + f"Access denied: {path} is the Hermes real-profile browser " + "snapshot directory (copied cookies/logins) and cannot be read " + "directly. (Defense-in-depth — not a security boundary; the " + "terminal tool can still bypass.)" + ) + try: + resolved.relative_to(browser_profile) + except ValueError: + continue + return ( + f"Access denied: {path} is inside the Hermes real-profile browser " + "snapshot (copied cookies/logins) and cannot be read directly. " + "(Defense-in-depth — not a security boundary; the terminal tool " + "can still bypass.)" + ) + # Block common secret-bearing project-local .env files anywhere on disk. # The agent helping a user with their project rarely needs to read raw # .env contents — .env.example is the documented-shape substitute. The diff --git a/hermes_cli/backup.py b/hermes_cli/backup.py index 4a86594197..c831ea7b78 100644 --- a/hermes_cli/backup.py +++ b/hermes_cli/backup.py @@ -82,6 +82,12 @@ _EXCLUDED_DIRS = { # the busy timeout — a full backup hangs mid-archive on the first locked DB. # Profiles are regenerable (cache + re-login) and unsafe to snapshot live. "browser-profiles", + # Real-profile browsing snapshot (browser.use_real_profile). Holds copies of + # the user's Cookies / Login Data / Web Data — a credential-bearing store + # that must NOT enter a backup archive. It is regenerated from the user's + # live profile on the next consented launch. Singular, distinct from the + # ``browser-profiles`` CDP dir above; both are excluded. + "browser-profile", # Python dependency trees (plugin / MCP-server venvs under HERMES_HOME) — # regenerated by reinstalling; never irreplaceable state. ".venv", diff --git a/hermes_cli/browser_connect.py b/hermes_cli/browser_connect.py index 28720c4d62..5f310d1511 100644 --- a/hermes_cli/browser_connect.py +++ b/hermes_cli/browser_connect.py @@ -94,8 +94,11 @@ _LINUX_INSTALL_PATHS = tuple(path for _, paths in _LINUX_BROWSER_GROUPS for path _CHROMIUM_BROWSERS = ("chrome", "edge", "brave", "chromium") # Windows UserChoice ProgId prefixes → canonical browser key. Matched -# case-insensitively by prefix so channel/version suffixes (e.g. -# ``ChromeHTML.X``, ``MSEdgeHTM``) still resolve. +# case-insensitively by prefix so version suffixes (e.g. ``ChromeHTML.X``) +# still resolve to STABLE. Pre-release channels have their own ProgIds and +# MUST be matched first (see _WINDOWS_CHANNEL_PROGIDS) so they are never +# swallowed into the stable family — driving the wrong profile is a +# wrong-principal bug (#95549 invariant). _WINDOWS_PROGID_MAP = ( ("chromehtml", "chrome"), ("msedgehtm", "edge"), @@ -103,9 +106,22 @@ _WINDOWS_PROGID_MAP = ( ("chromiumhtm", "chromium"), ) -# Linux xdg default-web-browser .desktop name fragments → canonical key. +# Pre-release ProgId prefixes we recognize but do NOT support (their profiles +# live in channel-specific dirs the resolver tables don't carry). Matched +# BEFORE the stable map; a hit fails closed rather than resolving to stable. +# ``ChromeBHTML`` = Beta, ``ChromeDHTML`` = Dev, ``ChromeSSHTML`` = Canary +# (SxS); ``MSEdgeBHTML`` / ``MSEdgeDHTML`` / ``MSEdgeCHTML`` = Edge channels. +_WINDOWS_CHANNEL_PROGIDS = ( + "chromebhtml", "chromedhtml", "chromesshtml", "chromecanaryhtml", + "msedgebhtml", "msedgedhtml", "msedgechtml", + "bravebetahtml", "bravenightlyhtml", +) + +# Linux xdg default-web-browser .desktop name fragments → canonical STABLE key. # Includes the Flatpak application ids (``com.google.Chrome.desktop`` etc.), -# which share none of the native package name fragments. +# which share none of the native package name fragments. Anchored so a channel +# .desktop (``google-chrome-beta``, ``com.google.chrome.beta``) does NOT match +# the stable fragment — channels are caught by _LINUX_CHANNEL_FRAGMENTS first. _LINUX_DESKTOP_MAP = ( ("google-chrome", "chrome"), ("com.google.chrome", "chrome"), @@ -116,6 +132,15 @@ _LINUX_DESKTOP_MAP = ( ("msedge", "edge"), ) +# Non-stable Linux channel .desktop fragments — recognized, unsupported. +# Checked before the stable map; a hit fails closed. +_LINUX_CHANNEL_FRAGMENTS = ( + "google-chrome-beta", "google-chrome-unstable", "google-chrome-canary", + "com.google.chrome.beta", "com.google.chrome.dev", "com.google.chrome.canary", + "microsoft-edge-beta", "microsoft-edge-dev", "microsoft-edge-canary", + "brave-browser-beta", "brave-browser-nightly", "brave-browser-dev", +) + # Where sandboxed Linux packages keep the profile instead of $XDG_CONFIG_HOME. _LINUX_FLATPAK_IDS = { "chrome": "com.google.Chrome", @@ -128,7 +153,8 @@ _LINUX_SNAP_PROFILE_PARTS = { "brave": ("snap", "brave", "current", ".config", "BraveSoftware", "Brave-Browser"), } -# macOS LaunchServices bundle-id fragments → canonical key. +# macOS LaunchServices bundle-id → canonical STABLE key. EXACT match (not +# prefix): ``com.google.chrome.beta`` must not be read as ``com.google.chrome``. _DARWIN_BUNDLE_MAP = ( ("com.google.chrome", "chrome"), ("com.microsoft.edgemac", "edge"), @@ -136,6 +162,19 @@ _DARWIN_BUNDLE_MAP = ( ("org.chromium.chromium", "chromium"), ) +# Non-stable macOS channel bundle ids — recognized, unsupported. Checked first. +_DARWIN_CHANNEL_BUNDLES = ( + "com.google.chrome.beta", "com.google.chrome.dev", "com.google.chrome.canary", + "com.microsoft.edgemac.beta", "com.microsoft.edgemac.dev", "com.microsoft.edgemac.canary", + "com.brave.browser.beta", "com.brave.browser.nightly", +) + +# Sentinel returned when the OS default is a recognized-but-unsupported +# Chromium CHANNEL (Beta/Dev/Canary). Distinct from None (non-Chromium) so the +# caller fails closed with a channel-specific message instead of driving the +# stable profile of a different account. +UNSUPPORTED_CHANNEL = "__unsupported_channel__" + def _real_profile_relparts(browser: str) -> tuple: """(mac_support_subdir, windows_localappdata_parts, linux_config_name).""" @@ -269,6 +308,11 @@ def _detect_default_windows() -> str | None: except Exception: return None low = str(prog_id or "").lower() + # Channels first: a recognized Beta/Dev/Canary ProgId must fail closed, not + # fall through to a stable prefix match and drive the stable profile. + for chan in _WINDOWS_CHANNEL_PROGIDS: + if low.startswith(chan): + return UNSUPPORTED_CHANNEL for prefix, browser in _WINDOWS_PROGID_MAP: if low.startswith(prefix): return browser @@ -337,12 +381,16 @@ def _detect_default_darwin() -> str | None: bundle = _launchservices_https_handler(out) if not bundle: return None + b = bundle.lower() + # Channels first (exact): a Beta/Dev/Canary bundle must fail closed. + if b in _DARWIN_CHANNEL_BUNDLES: + return UNSUPPORTED_CHANNEL for frag, browser in _DARWIN_BUNDLE_MAP: - if bundle.startswith(frag): + if b == frag: return browser - # A non-Chromium https handler (Safari, Firefox, Arc, …): fail closed. - # No "first installed Chromium wins" fallback — that would drive a - # browser the user never made their default. + # A non-Chromium https handler (Safari, Firefox, Arc, …) or an unknown + # channel bundle: fail closed. No "first installed Chromium wins" fallback + # — that would drive a browser the user never made their default. return None @@ -358,6 +406,12 @@ def _detect_default_linux() -> str | None: ).stdout.strip().lower() except Exception: out = "" + # Channels first: ``google-chrome-beta.desktop`` contains the stable + # ``google-chrome`` fragment, so a substring match would drive stable. + # Catch recognized channels and fail closed instead. + for frag in _LINUX_CHANNEL_FRAGMENTS: + if frag in out: + return UNSUPPORTED_CHANNEL for frag, browser in _LINUX_DESKTOP_MAP: if frag in out: return browser @@ -451,6 +505,23 @@ def real_profile_copy_dir(browser: str) -> str: return str(get_hermes_home() / "browser-profile" / browser) +def _secure_snapshot_root(path: str) -> None: + """Lock down the snapshot dir through Hermes' canonical secret-store policy. + + The snapshot holds copies of the user's Cookies / Login Data, so it is a + credential store and must get the same owner-only permissions (and + managed-mode / NixOS group-share carve-out, HERMES_UID/GID ownership) as + every other Hermes secret dir — via ``hermes_cli.config._secure_dir``, + not a bespoke chmod. Deferred import avoids a config↔browser import cycle. + """ + try: + from hermes_cli.config import _secure_dir + + _secure_dir(path) + except Exception as e: # never block a launch on a permissions best-effort + logger.debug("could not secure real-profile snapshot dir %s: %s", path, e) + + def _profile_subdirs(src: str) -> list[str]: """Names of per-profile dirs (Default, Profile 1, ...) inside a data dir.""" out = [] @@ -486,6 +557,7 @@ def snapshot_real_profile(browser: str, src: str | None = None) -> tuple[str | N try: if fresh: os.makedirs(dst, exist_ok=True) + _secure_snapshot_root(dst) try: shutil.copytree( src, diff --git a/tests/tools/test_browser_real_profile.py b/tests/tools/test_browser_real_profile.py index 5cf447027a..d36ae21b1a 100644 --- a/tests/tools/test_browser_real_profile.py +++ b/tests/tools/test_browser_real_profile.py @@ -390,3 +390,121 @@ class TestNavigationRouting: patch.object(bt, "_url_is_private", return_value=False): key = bt._navigation_session_key("t1", "https://example.com") assert key == "t1" + + +class TestChannelIdentity: + """#95549 invariant: pre-release channels must NOT normalize to stable. + + Swallowing Beta/Dev/Canary into the stable family drives a different + profile/account — a wrong-principal bug. Detection must flag the channel + (UNSUPPORTED_CHANNEL) so the caller fails closed, never returning 'chrome' + for a Beta default. + """ + + def test_linux_beta_not_normalized_to_stable(self): + import hermes_cli.browser_connect as bc + with patch.object(bc.subprocess, "run", + return_value=Mock(stdout="google-chrome-beta.desktop\n")): + assert bc._detect_default_linux() == bc.UNSUPPORTED_CHANNEL + + def test_linux_stable_still_resolves(self): + import hermes_cli.browser_connect as bc + with patch.object(bc.subprocess, "run", + return_value=Mock(stdout="google-chrome.desktop\n")): + assert bc._detect_default_linux() == "chrome" + + def test_linux_flatpak_beta_not_stable(self): + import hermes_cli.browser_connect as bc + with patch.object(bc.subprocess, "run", + return_value=Mock(stdout="com.google.chrome.beta.desktop\n")): + assert bc._detect_default_linux() == bc.UNSUPPORTED_CHANNEL + + def test_darwin_canary_not_normalized(self): + import hermes_cli.browser_connect as bc + with patch.object(bc, "_launchservices_https_handler", + return_value="com.google.chrome.canary"): + with patch.object(bc.subprocess, "run", return_value=Mock(stdout="")): + assert bc._detect_default_darwin() == bc.UNSUPPORTED_CHANNEL + + def test_darwin_stable_exact_match(self): + import hermes_cli.browser_connect as bc + with patch.object(bc, "_launchservices_https_handler", + return_value="com.google.chrome"): + with patch.object(bc.subprocess, "run", return_value=Mock(stdout="")): + assert bc._detect_default_darwin() == "chrome" + + def test_windows_progid_maps(self): + import hermes_cli.browser_connect as bc + # Stable ProgIds → family; channel ProgIds are in the channel set. + assert dict(bc._WINDOWS_PROGID_MAP)["chromehtml"] == "chrome" + assert "chromebhtml" in bc._WINDOWS_CHANNEL_PROGIDS # Beta + assert "msedgebhtml" in bc._WINDOWS_CHANNEL_PROGIDS # Edge Beta + # A channel ProgId must not be a prefix hit for any stable entry. + for chan in bc._WINDOWS_CHANNEL_PROGIDS: + assert not any(chan.startswith(p) for p, _ in bc._WINDOWS_PROGID_MAP) + + def test_channel_sentinel_fails_closed_in_cdp(self): + """A channel default → _real_profile_cdp fails closed, never launches.""" + import tools.browser_tool as bt + import hermes_cli.browser_connect as bc + bt._real_profile_cdp_cache.clear() + with patch.object(bt, "_use_real_profile", return_value=True), \ + patch("hermes_cli.browser_connect.detect_default_chromium", + return_value=bc.UNSUPPORTED_CHANNEL), \ + patch("hermes_cli.browser_connect.snapshot_real_profile") as snap: + cdp, err = bt._real_profile_cdp() + assert cdp is None + assert err and "pre-release" in err.lower() + snap.assert_not_called() # never even snapshotted a stable profile + bt._real_profile_cdp_cache.clear() + + def test_data_dir_rejects_sentinel(self): + import hermes_cli.browser_connect as bc + assert bc.real_profile_data_dir(bc.UNSUPPORTED_CHANNEL, "Linux") is None + assert bc.chromium_executable(bc.UNSUPPORTED_CHANNEL, "Linux") is None + + +class TestSnapshotIsCredentialStore: + """The copied Cookies/Login Data must live inside Hermes' secret lifecycle.""" + + def test_excluded_from_backup(self): + import hermes_cli.backup as bk + # Exact-component match (both singular and plural browser dirs). + assert "browser-profile" in bk._EXCLUDED_DIRS + assert bk._should_exclude( + __import__("pathlib").Path("browser-profile/chrome/Default/Cookies") + ) + + def test_read_guard_blocks_snapshot(self, tmp_path, monkeypatch): + import agent.file_safety as fs + home = tmp_path / ".hermes" + (home / "browser-profile" / "chrome" / "Default").mkdir(parents=True) + cookies = home / "browser-profile" / "chrome" / "Default" / "Cookies" + cookies.write_text("secret-cookie-db") + monkeypatch.setenv("HERMES_HOME", str(home)) + err = fs.get_read_block_error(str(cookies)) + assert err and "snapshot" in err.lower() + + def test_read_guard_allows_normal_file(self, tmp_path, monkeypatch): + import agent.file_safety as fs + home = tmp_path / ".hermes" + home.mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(home)) + normal = tmp_path / "notes.txt" + normal.write_text("hello") + assert fs.get_read_block_error(str(normal)) is None + + def test_snapshot_dir_secured(self, tmp_path, monkeypatch): + """snapshot_real_profile locks the dir via the canonical _secure_dir.""" + import hermes_cli.browser_connect as bc + src = tmp_path / "real" / "Default" + src.mkdir(parents=True) + (tmp_path / "real" / "Local State").write_text("{}") + (src / "Cookies").write_text("db") + monkeypatch.setattr(bc, "get_hermes_home", lambda: tmp_path / "hh") + called = {} + with patch("hermes_cli.config._secure_dir", + side_effect=lambda p: called.__setitem__("p", p)): + dst, err = bc.snapshot_real_profile("chrome", src=str(tmp_path / "real")) + assert err is None + assert called.get("p") == dst # secured through the canonical owner diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 98cb033452..0d4bc55616 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -1555,6 +1555,7 @@ def _real_profile_cdp() -> tuple: return None, None from hermes_cli.browser_connect import ( + UNSUPPORTED_CHANNEL, detect_default_chromium, snapshot_real_profile, ) @@ -1574,6 +1575,18 @@ def _real_profile_cdp() -> tuple: "Real-profile browsing requires a Chromium default; set one or turn " "the toggle off." ) + if browser == UNSUPPORTED_CHANNEL: + # A recognized pre-release channel (Beta/Dev/Canary) is the OS + # default. Its profile lives in a channel-specific directory we + # don't resolve, and normalizing it to the stable family would + # drive a DIFFERENT profile/account — a wrong-principal bug. Fail + # closed rather than guess (#95549 invariant). + return None, ( + "browser.use_real_profile is on, but your default browser is a " + "pre-release Chromium channel (Beta / Dev / Canary), which " + "real-profile browsing does not support. Set your default to a " + "stable Chrome / Edge / Brave / Chromium, or turn the toggle off." + ) copy_dir, err = snapshot_real_profile(browser) if err or not copy_dir: return None, f"browser.use_real_profile is on, but {err}"