{"repo":"NousResearch/hermes-agent","public_id":"55647","display_label":"Background-review forks writing skills without reading existing content first","summary":"The members concern background-review forks that patch skills via skill_manage, with one issue reporting hallucinated existing skill content and no read-before-write invariant. The related pull requests propose requiring a read before writing and guarding against large content loss in patches.","subsystem":null,"counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":55906,"merged_at":"2026-06-30T22:49:36+00:00"},"resolution":{"state":"resolved","by":"pr-55906"},"freshness":{"state":"verified","last_verified_at":"2026-06-30T22:49:36+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":55647,"pr_number":55906,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-30T22:49:36+00:00"}],"members":[{"kind":"issue","number":55647,"title":"background-review: skill_manage patches hallucinate existing skill content; review fork writes without read-before-write invariant","url":null,"state":"closed"},{"kind":"pr","number":55657,"title":"fix(skills): require review forks to read before writing skills","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"20871c1d9","number":0},{"kind":"pr","value":"#55906","number":55906}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":55665,"title":"fix(tools): guard against catastrophic content loss in background review patches","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#55906","number":55906},{"kind":"pr","value":"#55647","number":55647}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":55906,"title":"fix(skills): require background-review forks to read before writing skills (#55647)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/background_review.py","scripts/release.py","tests/tools/test_skill_manager_tool.py","tools/skill_manager_tool.py","tools/skills_tool.py"],"fix_hunks":[{"file":"agent/background_review.py","hunks":["@@ -739,6 +739,13 @@ def _bg_review_auto_deny(command, description, **kwargs):\n                     \"{tool_name}. Only memory/skill tools are allowed.\"\n                 ),\n             )\n+            try:\n+                from tools.skill_manager_tool import _reset_background_review_read_marks\n+\n+                _reset_background_review_read_marks()\n+            except Exception:\n+                pass\n+\n             try:\n                 # Routed to a different model -> replay a digest (cache is cold\n                 # on that model anyway, so minimise cold-written tokens). Same"]},{"file":"scripts/release.py","hunks":["@@ -271,6 +271,7 @@\n     \"[redacted:email]\": \"zhaoleibd\",\n     \"[redacted:email]\": \"jeffrobodie-glitch\",\n     \"kyssta-exe@users.noreply.github.com\": \"kyssta-exe\",\n+    \"218078013+kyssta-exe@users.noreply.github.com\": \"kyssta-exe\",  # PR #55657 salvage (read-before-write invariant for background-review skill patches; #55647)\n     \"[redacted:email]\": \"ITheEqualizer\",\n     \"[redacted:email]\": \"stremtec\",\n     \"[redacted:email]\": \"solaitken\","]},{"file":"tests/tools/test_skill_manager_tool.py","hunks":["@@ -957,6 +957,9 @@ def test_background_review_unpinned_skill_not_blocked_by_pin_guard(self, tmp_pat\n             _create_skill(\"my-skill\", VALID_SKILL_CONTENT)\n             token = [redacted:secret-assignment]\n             try:\n+                from tools.skill_manager_tool import mark_background_review_skill_read\n+\n+                mark_background_review_skill_read(tmp_path / \"my-skill\" / \"SKILL.md\")\n                 with patch(\n                     \"tools.skill_usage.get_record\",\n                     side_effect=lambda n: {\"pinned\": False},","@@ -1173,6 +1176,7 @@ def _curator_pass(tmp_path, *, monkeypatch):\n     skills_root.mkdir(parents=True, exist_ok=True)\n     monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n     with patch(\"tools.skill_manager_tool.SKILLS_DIR\", skills_root), \\\n+         patch(\"tools.skills_tool.SKILLS_DIR\", skills_root), \\\n          patch(\"agent.skill_utils.get_all_skills_dirs\", return_value=[skills_root]), \\\n          patch(\"tools.skill_provenance.is_background_review\", return_value=True):\n         yield skills_root","@@ -1281,3 +1285,66 @@ def test_dispatcher_preserves_usage_record_on_curator_archive(self, tmp_path, mo\n             rec = skill_usage.get_record(\"narrow\")\n         # Record kept (not forgotten) and marked archived.\n         assert rec.get(\"state\") == skill_usage.STATE_ARCHIVED\n+\n+    def test_background_review_patch_requires_skill_view_first(self, tmp_path, monkeypatch):\n+        from tools.skills_tool import skill_view\n+        from tools.skill_manager_tool import _reset_background_review_read_marks\n+\n+        _reset_background_review_read_marks()\n+        with _curator_pass(tmp_path, monkeypatch=monkeypatch):\n+            _create_skill(\"reviewed\", _skill_content(\"reviewed\"))\n+\n+            blocked = json.loads(skill_manage(\n+                action=\"patch\",\n+                name=\"reviewed\",\n+                old_string=\"Step 1: Do the thing.\",\n+                new_string=\"Step 1: Do the thing safely.\",\n+            ))\n+            assert blocked[\"success\"] is False\n+            assert blocked.get(\"_read_before_write_required\") is True\n+\n+            viewed = json.loads(skill_view(\"reviewed\"))\n+            assert viewed[\"success\"] is True\n+\n+            allowed = json.loads(skill_manage(\n+                action=\"patch\",\n+                name=\"reviewed\",\n+                old_string=\"Step 1: Do the thing.\",\n+                new_string=\"Step 1: Do the thing safely.\",\n+            ))\n+            assert allowed[\"success\"] is True, allowed\n+\n+        _reset_background_review_read_marks()\n+\n+    def test_background_review_support_file_overwrite_requires_that_file_read(self, tmp_path, monkeypatch):\n+        from tools.skills_tool import skill_view\n+        from tools.skill_manager_tool import _reset_background_review_read_marks\n+\n+        _reset_background_review_read_marks()\n+        with _curator_pass(tmp_path, monkeypatch=monkeypatch):\n+            _create_skill(\"reviewed\", _skill_content(\"reviewed\"))\n+            ref = tmp_path / \".hermes\" / \"skills\" / \"reviewed\" / \"references\"\n+            ref.mkdir()\n+            (ref / \"workflow.md\").write_text(\"old workflow\\n\", encoding=\"utf-8\")\n+\n+            # Reading SKILL.md does not authorize overwriting a linked file.\n+            assert json.loads(skill_view(\"reviewed\"))[\"success\"] is True\n+            blocked = json.loads(skill_manage(\n+                action=\"write_file\",\n+                name=\"reviewed\",\n+                file_path=\"references/workflow.md\",\n+                file_content=\"new workflow\\n\",\n+            ))\n+            assert blocked[\"success\"] is False\n+            assert blocked.get(\"_read_before_write_required\") is True\n+\n+            assert json.loads(skill_view(\"reviewed\", \"references/workflow.md\"))[\"success\"] is True\n+            allowed = json.loads(skill_manage(\n+                action=\"write_file\",\n+                name=\"reviewed\",\n+                file_path=\"references/workflow.md\",\n+                file_content=\"new workflow\\n\",\n+            ))\n+            assert allowed[\"success\"] is True, allowed\n+\n+        _reset_background_review_read_marks()"]},{"file":"tools/skill_manager_tool.py","hunks":["@@ -38,15 +38,58 @@\n import re\n import shutil\n import tempfile\n+import contextvars as _ctxvars\n from pathlib import Path\n-from hermes_constants import get_hermes_home, display_hermes_home\n-from typing import Dict, Any, List, Optional, Tuple\n+from typing import Any, Dict, List, Optional, Tuple\n \n+from hermes_constants import get_hermes_home, display_hermes_home\n from utils import atomic_replace, is_truthy_value\n from hermes_cli.config import cfg_get\n \n logger = logging.getLogger(__name__)\n \n+_background_review_read_paths: \"_ctxvars.ContextVar[frozenset[str]]\" = _ctxvars.ContextVar(\n+    \"background_review_read_paths\", default=frozenset()\n+)\n+\n+\n+def mark_background_review_skill_read(path: Path) -> None:\n+    \"\"\"Record that the active background-review fork has read a skill file.\n+\n+    The autonomous review fork is allowed to evolve skills, but it must not\n+    patch or rewrite content it has only inferred from the transcript.  The\n+    skill_view tool calls this after returning file content to the model; write\n+    paths below require the corresponding target path to be present when the\n+    current origin is ``background_review``.\n+    \"\"\"\n+    try:\n+        from tools.skill_provenance import is_background_review\n+        if not is_background_review():\n+            return\n+    except Exception:\n+        return\n+\n+    try:\n+        resolved = str(path.resolve())\n+    except Exception:\n+        resolved = str(path)\n+    current = set(_background_review_read_paths.get())\n+    current.add(resolved)\n+    _background_review_read_paths.set(frozenset(current))\n+\n+\n+def _background_review_has_read(path: Path) -> bool:\n+    try:\n+        resolved = str(path.resolve())\n+    except Exception:\n+        resolved = str(path)\n+    return resolved in _background_review_read_paths.get()\n+\n+\n+def _reset_background_review_read_marks() -> None:\n+    \"\"\"Test helper: clear read-before-write marks for the current context.\"\"\"\n+    _background_review_read_paths.set(frozenset())\n+\n # Import security scanner — external hub installs always get scanned;\n # agent-created skills only get scanned when skills.guard_agent_created is on.\n try:","@@ -320,6 +363,36 @@ def _background_review_write_guard(\n     return None\n \n \n+def _background_review_read_before_write_guard(\n+    name: str,\n+    target: Path,\n+    action: str,\n+    file_label: str,\n+) -> Optional[Dict[str, Any]]:\n+    \"\"\"Require review forks to load the exact target before mutating it.\"\"\"\n+    try:\n+        from tools.skill_provenance import is_background_review\n+        if not is_background_review():\n+            return None\n+    except Exception:\n+        return None\n+\n+    if _background_review_has_read(target):\n+        return None\n+\n+    return {\n+        \"success\": False,\n+        \"error\": (\n+            f\"Refusing background curator {action} for skill '{name}': \"\n+            f\"the current {file_label} content has not been loaded in this \"\n+            \"review turn. Call skill_view(name) for SKILL.md, or \"\n+            \"skill_view(name, file_path=...) for a supporting file, then \"\n+            \"retry the write using the content just returned.\"\n+        ),\n+        \"_read_before_write_required\": True,\n+    }\n+\n+\n def _background_review_preflight(action: str, name: str) -> Optional[Dict[str, Any]]:\n     if action not in {\"edit\", \"patch\", \"delete\", \"write_file\", \"remove_file\"}:\n         return None","@@ -786,6 +859,12 @@ def _edit_skill(name: str, content: str) -> Dict[str, Any]:\n         return guard\n \n     skill_md = existing[\"path\"] / \"SKILL.md\"\n+    read_guard = _background_review_read_before_write_guard(\n+        name, skill_md, \"edit\", \"SKILL.md\"\n+    )\n+    if read_guard:\n+        return read_guard\n+\n     # Back up original content for rollback\n     original_content = skill_md.read_text(encoding=\"utf-8\") if skill_md.exists() else None\n     _atomic_write_text(skill_md, content)","@@ -849,6 +928,7 @@ def _patch_skill(\n         target, err = _resolve_skill_target(skill_dir, file_path)\n         if err:\n             return {\"success\": False, \"error\": err}\n+        assert target is not None\n     else:\n         # Patching SKILL.md\n         target = skill_dir / \"SKILL.md\"","@@ -856,6 +936,15 @@ def _patch_skill(\n     if not target.exists():\n         return {\"success\": False, \"error\": f\"File not found: {target.relative_to(skill_dir)}\"}\n \n+    read_guard = _background_review_read_before_write_guard(\n+        name,\n+        target,\n+        \"patch\",\n+        \"SKILL.md\" if not file_path else file_path,\n+    )\n+    if read_guard:\n+        return read_guard\n+\n     content = target.read_text(encoding=\"utf-8\")\n \n     # Use the same fuzzy matching engine as the file patch tool.","@@ -1057,6 +1146,13 @@ def _write_file(name: str, file_path: str, file_content: str) -> Dict[str, Any]:\n     target, err = _resolve_skill_target(existing[\"path\"], file_path)\n     if err:\n         return {\"success\": False, \"error\": err}\n+    assert target is not None\n+    if target.exists():\n+        read_guard = _background_review_read_before_write_guard(\n+            name, target, \"write_file\", file_path\n+        )\n+        if read_guard:\n+            return read_guard\n     target.parent.mkdir(parents=True, exist_ok=True)\n     # Back up for rollback\n     original_content = target.read_text(encoding=\"utf-8\") if target.exists() else None","@@ -1096,6 +1192,7 @@ def _remove_file(name: str, file_path: str) -> Dict[str, Any]:\n     target, err = _resolve_skill_target(skill_dir, file_path)\n     if err:\n         return {\"success\": False, \"error\": err}\n+    assert target is not None\n     if not target.exists():\n         # List what's actually there for the model to see\n         available = []","@@ -1111,6 +1208,12 @@ def _remove_file(name: str, file_path: str) -> Dict[str, Any]:\n             \"available_files\": available if available else None,\n         }\n \n+    read_guard = _background_review_read_before_write_guard(\n+        name, target, \"remove_file\", file_path\n+    )\n+    if read_guard:\n+        return read_guard\n+\n     target.unlink()\n \n     # Clean up empty subdirectories"]},{"file":"tools/skills_tool.py","hunks":["@@ -1281,6 +1281,17 @@ def _record(sd: Optional[Path], smd: Path) -> None:\n                     ensure_ascii=False,\n                 )\n \n+            try:\n+                from tools.skill_manager_tool import mark_background_review_skill_read\n+\n+                mark_background_review_skill_read(target_file)\n+            except Exception:\n+                logger.debug(\n+                    \"Could not record background-review skill read for %s\",\n+                    target_file,\n+                    exc_info=True,\n+                )\n+\n             return json.dumps(\n                 {\n                     \"success\": True,","@@ -1484,6 +1495,17 @@ def _record(sd: Optional[Path], smd: Path) -> None:\n         if capture_result[\"gateway_setup_hint\"]:\n             result[\"gateway_setup_hint\"] = capture_result[\"gateway_setup_hint\"]\n \n+        try:\n+            from tools.skill_manager_tool import mark_background_review_skill_read\n+\n+            mark_background_review_skill_read(skill_md)\n+        except Exception:\n+            logger.debug(\n+                \"Could not record background-review skill read for %s\",\n+                skill_md,\n+                exc_info=True,\n+            )\n+\n         if setup_needed:\n             missing_items = [\n                 f\"env ${env_name}\" for env_name in remaining_missing_required_envs\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}