diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 9a81cddb98..acd945330d 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -163,7 +163,7 @@ PROFILE_CREDENTIAL_PATHS = frozenset({ "mcp-tokens", # MCP OAuth tokens "vault", # vault.key + vault.json.enc "browser-profile", "browser_auth", "bot-desktop", # browser cookies / logins - "pairing", "platforms/pairing", + "pairing", "platforms/pairing", "feishu_comment_pairing.json", "whatsapp/session", "platforms/whatsapp/session", "matrix/store", "platforms/matrix/store", "cache/bws_cache.json", "cache/bws_cache.enc.json", }) @@ -2200,6 +2200,15 @@ def _default_export_ignore(root_dir: Path): # its persistent Chromium profile (Cookies, Login Data — the bot's live web sessions), Xauthority, sockets. _EXPORT_CREDENTIAL_FILES = frozenset({"auth.json", ".env", "bot-desktop"}) +# Recovery copies Hermes writes of those stores at a profile's root, dropped from a named-profile +# export (the default export's root allow-list already omits them). ``backups/`` holds pre-update +# zips of the whole home and config.yaml copies; ``state-snapshots/`` holds per-profile update +# snapshots (pairing stores, state.db). The sibling copies (``auth.json.corrupt``, the config +# migration's ``.env.bak-`` / ``config.yaml.bak-``, legacy ``config.yaml.bak.`` +# and ``config.yaml.corrupt.*``) end in suffixes the text scrub never edits. +_EXPORT_RECOVERY_ROOT_DIRS = frozenset({"backups", "state-snapshots"}) +_EXPORT_STORE_COPY_PREFIXES = ("auth.json.", ".env.bak", "config.yaml.bak", "config.yaml.corrupt") + # Text/config suffixes secret-scrubbed on export; binary DBs, images etc. are left alone. _EXPORT_REDACT_SUFFIXES = frozenset({ ".md", ".txt", ".yaml", ".yml", ".json", ".jsonl", ".toml", ".ini", ".cfg", ".conf", ".py", ".sh", @@ -2261,7 +2270,8 @@ def export_profile(name: str, output_path: str, extra_files: Optional[Dict[str, rel = Path(directory).relative_to(profile_dir) ignored.update(e for e in contents if (rel / e).as_posix() in PROFILE_CREDENTIAL_PATHS) if Path(directory) == profile_dir: - ignored |= PM_RUNTIME_ROOT_DIRS & set(contents) + ignored |= (PM_RUNTIME_ROOT_DIRS | _EXPORT_RECOVERY_ROOT_DIRS) & set(contents) + ignored.update(e for e in contents if e.startswith(_EXPORT_STORE_COPY_PREFIXES)) return ignored ignore = _default_export_ignore(profile_dir) if canon == "default" else _ignore_credentials diff --git a/tests/hermes_cli/test_profile_export_credentials.py b/tests/hermes_cli/test_profile_export_credentials.py index 39324d1344..7282956ad1 100644 --- a/tests/hermes_cli/test_profile_export_credentials.py +++ b/tests/hermes_cli/test_profile_export_credentials.py @@ -84,6 +84,43 @@ class TestCredentialExclusion: root_stores = {r for r in stores if "/" not in r} assert root_stores <= USER_OWNED_EXCLUDE, sorted(root_stores - USER_OWNED_EXCLUDE) + def test_export_ships_no_recovery_copy_hermes_writes_of_a_store(self, tmp_path, monkeypatch): + """Every copy Hermes' own writers leave in a profile home (pre-update zip, update snapshot, + config-migration .bak, config backup, corrupt auth.json) stays out of a named-profile + export: each carries the same credentials, and none ends in a suffix the scrub edits.""" + from hermes_cli.auth import _load_auth_store + from hermes_cli.backup import create_pre_update_backup, create_quick_snapshot + from hermes_cli.config_backups import backup_config + from hermes_cli.post_update import _backup_existing + + profiles_root = tmp_path / "profiles" + profile_dir = profiles_root / "testprofile" + (profile_dir / "pairing").mkdir(parents=True) + (profile_dir / "config.yaml").write_text(f"model:\n api_key: {_LEAKED_KEY}\n") + (profile_dir / ".env").write_text(f"OPENROUTER_API_KEY={_LEAKED_KEY}\n") + (profile_dir / "pairing" / "telegram-approved.json").write_text('{"123": {}}') + (profile_dir / "auth.json").write_text('{"providers": {"x": {"api_key": "' + _LEAKED_KEY + '"') + monkeypatch.setenv("HERMES_HOME", str(profile_dir)) + + copies = [ + create_pre_update_backup(hermes_home=profile_dir), + profile_dir / "state-snapshots" / create_quick_snapshot(hermes_home=profile_dir), + backup_config(profile_dir / "config.yaml", "setup"), + *_backup_existing((profile_dir / ".env", profile_dir / "config.yaml")).values(), + ] + _load_auth_store(profile_dir / "auth.json") + copies.append(profile_dir / "auth.json.corrupt") + assert all(c and c.exists() for c in copies), copies + _patch_named_profile(monkeypatch, profiles_root, profile_dir) + + with tarfile.open(export_profile("testprofile", str(tmp_path / "export.tar.gz")), "r:gz") as tf: + names = set(tf.getnames()) + + assert "testprofile/config.yaml" in names + rels = [c.relative_to(profile_dir).as_posix() for c in copies] + shipped = sorted(r for r in rels if any(n == f"testprofile/{r}" or n.startswith(f"testprofile/{r}/") for n in names)) + assert not shipped, shipped + class TestExportSecretScrub: diff --git a/website/docs/user-guide/profile-distributions.md b/website/docs/user-guide/profile-distributions.md index 109d3219d2..aaece2f243 100644 --- a/website/docs/user-guide/profile-distributions.md +++ b/website/docs/user-guide/profile-distributions.md @@ -686,13 +686,13 @@ You cannot import as `default` — that name is the built-in root profile (`~/.h ### What an export file contains -Always excluded, both profiles types: `auth.json` and `.env`. Your API keys never leave the machine. +Always excluded, both profile types: `auth.json`, `.env` and the other credential stores Hermes reads from a profile (OAuth token files, `mcp-tokens/`, `vault/`, browser profiles, platform sessions and pairing stores). Your API keys never leave the machine. **The default profile** (`~/.hermes`) is exported through an allow-list — only known Hermes artifacts, so an unrelated file sitting in your home directory can't get swept in: `config.yaml`, `SOUL.md`, `MEMORY.md`, `USER.md`, `todo.json`, `system_prompt.md`, `AGENTS.md`, `CLAUDE.md`, `.cursorrules`, `skills/`, `plugins/`, `cron/`, `scripts/`, `sessions/`, `memories/`, `knowledge/`, `preferences/`, and `desktop.json` when the desktop staged one. -**A named profile** (`~/.hermes/profiles/`) copies the whole directory minus `auth.json` / `.env`. That's broader — if the profile has `state.db`, logs, or caches, they go in the archive too, and the file gets big. +**A named profile** (`~/.hermes/profiles/`) copies the whole directory minus those credential stores and the recovery copies Hermes keeps of them (`backups/`, `state-snapshots/`, `auth.json.corrupt`, `.env.bak-*`, `config.yaml.bak-*`). That's broader — if the profile has `state.db`, logs, or caches, they go in the archive too, and the file gets big. :::caution Read your archive before you send it An export is a snapshot of your profile, not a curated release. Unlike a distribution, it **can** include `memories/`, `sessions/`, and `USER.md` — and nothing scans skills, memories, or your persona for anything personal you wrote into them. Credentials are filtered by filename; content is not. diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/profile-distributions.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/profile-distributions.md index 28641fb576..cfe5f72a7f 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/profile-distributions.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/profile-distributions.md @@ -66,7 +66,7 @@ hermes profile install github.com/you/my-research-agent --alias 不适合的场景: - **你只想在自己的机器上备份一个 profile。** 使用 [`hermes profile export` / `import`](../reference/profile-commands.md#hermes-profile-export)——那正是这两个命令的用途。 -- **你想随 agent 一起共享 API 密钥。** `auth.json` 和 `.env` 被刻意排除在分发之外。每个安装者使用自己的凭据。 +- **你想随 agent 一起共享 API 密钥。** `auth.json`、`.env` 以及 Hermes 从 profile 读取的其他凭据存储(`.op.env`、`npmrc`、OAuth token 文件、`mcp-tokens/`、`vault/`、浏览器 profile、平台会话)被刻意排除在分发之外。每个安装者使用自己的凭据。(导出文件同样会去除它们。) - **你想共享记忆 / 会话 / 对话历史。** 这些是用户数据,不是分发内容,永远不会被发送。 ## 生命周期:从作者到安装者再到更新 @@ -175,7 +175,7 @@ research-bot/ |---|---|---| | **分发所有** | `SOUL.md`、`config.yaml`、`mcp.json`、`skills/`、`cron/`、`distribution.yaml` | 从新克隆中替换 | | **配置覆盖** | `config.yaml` | 默认实际保留——安装者可能已调整模型或 provider。更新时传入 `--force-config` 可重置。 | -| **用户所有** | `memories/`、`sessions/`、`state.db*`、`auth.json`、`.env`、`logs/`、`workspace/`、`plans/`、`home/`、`*_cache/`、`local/` | 永不触碰 | +| **用户所有** | `memories/`、`sessions/`、`state.db*`、`auth.json`、`.env` 及其他凭据存储、`logs/`、`workspace/`、`plans/`、`home/`、`*_cache/`、`local/` | 永不触碰 | 你可以在 manifest 中覆盖分发所有列表: