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:
Teknium
2026-08-26 08:03:30 -07:00
parent 1f4d095fd8
commit f1d05ce7d8
5 changed files with 246 additions and 9 deletions
+28
View File
@@ -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
+6
View File
@@ -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",
+81 -9
View File
@@ -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,
+118
View File
@@ -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
+13
View File
@@ -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}"