{"repo":"NousResearch/hermes-agent","public_id":"10623","display_label":"Profile import name collision and clobbering fixes","summary":"These items address a bug where importing a profile archive could overwrite an existing profile when the archive's root directory name differs from the specified --name flag. The group includes the bug report and related fix attempts targeting staging and name reconciliation during import.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":14325,"merged_at":"2026-04-23T10:02:35+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"stale","last_verified_at":"2026-04-23T10:02:35+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":10623,"pr_number":14325,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-23T10:02:35+00:00"}],"members":[{"kind":"issue","number":10623,"title":"[Bug]: profile import can clobber an existing profile when archive root name differs from --name","url":null,"state":"closed"},{"kind":"pr","number":10649,"title":"fix(cli): prevent profile import from clobbering existing profile","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#14325","number":14325},{"kind":"pr","value":"#10623","number":10623}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":14325,"title":"fix(profiles): stage profile imports before target name reconciliation","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/profiles.py","tests/hermes_cli/test_profiles.py"],"fix_hunks":[{"file":"hermes_cli/profiles.py","hunks":["@@ -863,19 +863,15 @@ def _safe_extract_profile_archive(archive: Path, destination: Path) -> None:\n                 pass\n \n \n-def import_profile(archive_path: str, name: Optional[str] = None) -> Path:\n-    \"\"\"Import a profile from a tar.gz archive.\n+def _inspect_profile_archive_roots(archive: Path) -> set[str]:\n+    \"\"\"Return the archive's top-level directory names.\n \n-    If *name* is not given, infers it from the archive's top-level directory.\n-    Returns the imported profile directory.\n+    Profile imports expect exactly one root directory. Inspecting the archive\n+    before extraction lets us stage the import safely instead of mutating a\n+    live profile tree first and reconciling names later.\n     \"\"\"\n     import tarfile\n \n-    archive = Path(archive_path)\n-    if not archive.exists():\n-        raise FileNotFoundError(f\"Archive not found: {archive}\")\n-\n-    # Peek at the archive to find the top-level directory name\n     with tarfile.open(archive, \"r:gz\") as tf:\n         top_dirs = {\n             parts[0]","@@ -889,13 +885,33 @@ def import_profile(archive_path: str, name: Optional[str] = None) -> Path:\n                 for member in tf.getmembers()\n                 if member.isdir()\n             }\n+    return top_dirs\n+\n+\n+def import_profile(archive_path: str, name: Optional[str] = None) -> Path:\n+    \"\"\"Import a profile from a tar.gz archive.\n+\n+    If *name* is not given, infers it from the archive's top-level directory.\n+    Returns the imported profile directory.\n+    \"\"\"\n+    import tempfile\n \n-    inferred_name = name or (top_dirs.pop() if len(top_dirs) == 1 else None)\n+    archive = Path(archive_path)\n+    if not archive.exists():\n+        raise FileNotFoundError(f\"Archive not found: {archive}\")\n+\n+    top_dirs = _inspect_profile_archive_roots(archive)\n+    archive_root = top_dirs.pop() if len(top_dirs) == 1 else None\n+    inferred_name = name or archive_root\n     if not inferred_name:\n         raise ValueError(\n             \"Cannot determine profile name from archive. \"\n             \"Specify it explicitly: hermes profile import <archive> --name <name>\"\n         )\n+    if archive_root is None:\n+        raise ValueError(\n+            \"Profile archive must contain exactly one top-level directory.\"\n+        )\n \n     # Archives exported from the default profile have \"default/\" as top-level\n     # dir.  Importing as \"default\" would target ~/.hermes itself — disallow","@@ -914,12 +930,22 @@ def import_profile(archive_path: str, name: Optional[str] = None) -> Path:\n     profiles_root = _get_profiles_root()\n     profiles_root.mkdir(parents=True, exist_ok=True)\n \n-    _safe_extract_profile_archive(archive, profiles_root)\n+    with tempfile.TemporaryDirectory(prefix=\"hermes_profile_import_\") as tmpdir:\n+        staging_root = Path(tmpdir)\n+        _safe_extract_profile_archive(archive, staging_root)\n+\n+        extracted = staging_root / archive_root\n+        if not extracted.is_dir():\n+            raise ValueError(\n+                f\"Profile archive root is missing or invalid: {archive_root}\"\n+            )\n+\n+        final_source = extracted\n+        if archive_root != inferred_name:\n+            final_source = staging_root / inferred_name\n+            extracted.rename(final_source)\n \n-    # If the archive extracted under a different name, rename\n-    extracted = profiles_root / (top_dirs.pop() if top_dirs else inferred_name)\n-    if extracted != profile_dir and extracted.exists():\n-        extracted.rename(profile_dir)\n+        shutil.move(str(final_source), str(profile_dir))\n \n     return profile_dir\n "]},{"file":"tests/hermes_cli/test_profiles.py","hunks":["@@ -455,6 +455,47 @@ def test_import_to_existing_name_raises(self, profile_env, tmp_path):\n         with pytest.raises(FileExistsError):\n             import_profile(str(archive_path), name=\"coder\")\n \n+    def test_import_with_explicit_name_does_not_mutate_existing_archive_root_profile(\n+        self, profile_env, tmp_path\n+    ):\n+        create_profile(\"victim\", no_alias=True)\n+        victim_dir = get_profile_dir(\"victim\")\n+        (victim_dir / \"marker.txt\").write_text(\"original\")\n+\n+        archive_path = tmp_path / \"export\" / \"victim.tar.gz\"\n+        archive_path.parent.mkdir(parents=True, exist_ok=True)\n+        with tarfile.open(archive_path, \"w:gz\") as tf:\n+            data = b\"imported\"\n+            info = tarfile.TarInfo(\"victim/marker.txt\")\n+            info.size = len(data)\n+            tf.addfile(info, io.BytesIO(data))\n+\n+        imported = import_profile(str(archive_path), name=\"renamed\")\n+\n+        assert imported == get_profile_dir(\"renamed\")\n+        assert (imported / \"marker.txt\").read_text() == \"imported\"\n+        assert (victim_dir / \"marker.txt\").read_text() == \"original\"\n+\n+    def test_import_rejects_archive_with_multiple_top_level_directories(\n+        self, profile_env, tmp_path\n+    ):\n+        archive_path = tmp_path / \"export\" / \"multi-root.tar.gz\"\n+        archive_path.parent.mkdir(parents=True, exist_ok=True)\n+\n+        with tarfile.open(archive_path, \"w:gz\") as tf:\n+            for member_name, data in (\n+                (\"alpha/marker.txt\", b\"a\"),\n+                (\"beta/marker.txt\", b\"b\"),\n+            ):\n+                info = tarfile.TarInfo(member_name)\n+                info.size = len(data)\n+                tf.addfile(info, io.BytesIO(data))\n+\n+        with pytest.raises(ValueError, match=\"exactly one top-level directory\"):\n+            import_profile(str(archive_path), name=\"coder\")\n+\n+        assert not get_profile_dir(\"coder\").exists()\n+\n     def test_import_rejects_traversal_archive_member(self, profile_env, tmp_path):\n         archive_path = tmp_path / \"export\" / \"evil.tar.gz\"\n         archive_path.parent.mkdir(parents=True, exist_ok=True)\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}