fix(profiles): keep Hermes' recovery copies of credential stores out of named exports
A named-profile export dropped the stores themselves but shipped the copies Hermes writes of them: backups/ (pre-update zips of the whole home, config.yaml copies), state-snapshots/ (per-profile update snapshots with pairing stores), auth.json.corrupt, and the config migration's .env.bak-<stamp> / config.yaml.bak-<stamp>. None of their suffixes is one the text scrub edits. The default export's root allow-list already omits all of them. Also lists feishu_comment_pairing.json beside the other pairing stores, and mirrors the credential-store wording in the zh-Hans distributions page. (cherry picked from commit 0a24aeaaf55279bfa9af4cc9f5e80873fb611020)
This commit is contained in:
committed by
kshitij
parent
9c18f8c28c
commit
fc33275f22
@@ -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-<stamp>`` / ``config.yaml.bak-<stamp>``, legacy ``config.yaml.bak.<n>``
|
||||
# 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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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/<name>`) 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/<name>`) 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.
|
||||
|
||||
@@ -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 中覆盖分发所有列表:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user