fix(browser): real-profile snapshot is a first-class secret store + preserve channel identity
Addresses two P1 review blockers (kshitij / @kxee) on the real-profile feature: Credential-store lifecycle for ~/.hermes/browser-profile/ (copied Cookies/ Login Data): - exclude the singular 'browser-profile' dir from backup AND import (_EXCLUDED_DIRS drives both) — was silently archiving cookies/logins - add a browser-profile/ directory-PREFIX read-deny to agent/file_safety.py, same class as auth.json / mcp-tokens - secure the snapshot dir through the canonical hermes_cli.config._secure_dir (honors managed/NixOS group-share + HERMES_UID/GID), not a bespoke chmod Channel identity (#95549 invariant — never normalize Beta/Dev/Canary to stable, which would drive a different account's profile): - detect recognized pre-release channels FIRST (Win ProgIds, macOS bundle ids, Linux .desktop) and return UNSUPPORTED_CHANNEL - macOS bundle match is now EXACT (was startswith); Linux/Win channel-before- stable ordering; real_profile_data_dir/chromium_executable reject the sentinel - _real_profile_cdp fails closed with a channel-specific message, never snapshots Tests: channel-not-normalized (linux/darwin/windows), wrong-principal fail-closed, backup exclusion, read-guard block/allow, snapshot dir secured. 187 browser + 222 backup/file_safety pass. Live re-verified: real Gmail inbox still loads.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user