Background review fork tool whitelist and read-before-write fixes

NousResearch/hermes-agent · cluster 39996 · agent

Data as of

5 open items. Fix merged in #55906. Check it covers your case.

The short answer

4open issues
1open PR
37items grouped
Known?
Yes. 37 related items grouped here, 9 issues already closed.
Fix event
A fix was merged to main in PR #55906. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PRs #55906, #94530, #98254, #98278. That is separate from the fix event.
Still open
4 issues and 1 PR.

These issues and PRs center on the background/cron review fork's tool whitelist being too restrictive (blocking read_file and other read-only tools) and the resulting curator loops caused by a read-before-write guard not being satisfied. Additional adjacent concerns include narrowing the tool schema exposed to local/weak models and configuring which memory providers the background review can write to.

Open issues4

  • Issue#58475[Bug]: Background review agent repeatedly gets stuck — prompt ambiguity, vague tool whitelist, and incomprehensible error message
  • Issue#59244Bug: background memory review ignores configured memory provider and only writes legacy memory
  • Issue#63964blocker-report: background curator loops on skill_manage patch errors
  • Issue#64131Background curator skill_manage loop reports repeated guard/schema failures

Open PRs1

  • PR#75020fix: require skill_view before background skill writes

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #55906 fix(skills): require background-review forks to read before writing skills (#55647)

    merged to main

    files: agent/background_review.py, scripts/release.py, tests/tools/test_skill_manager_tool.py, tools/skill_manager_tool.py, tools/skills_tool.py

    Show the diff
    • agent/background_review.py
      @@ -739,6 +739,13 @@ def _bg_review_auto_deny(command, description, **kwargs):
                           "{tool_name}. Only memory/skill tools are allowed."
                       ),
                   )
      +            try:
      +                from tools.skill_manager_tool import _reset_background_review_read_marks
      +
      +                _reset_background_review_read_marks()
      +            except Exception:
      +                pass
      +
                   try:
                       # Routed to a different model -> replay a digest (cache is cold
                       # on that model anyway, so minimise cold-written tokens). Same
    • scripts/release.py
      @@ -271,6 +271,7 @@
           "[redacted:email]": "zhaoleibd",
           "[redacted:email]": "jeffrobodie-glitch",
           "[redacted:email]": "kyssta-exe",
      +    "[redacted:email]": "kyssta-exe",  # PR #55657 salvage (read-before-write invariant for background-review skill patches; #55647)
           "[redacted:email]": "ITheEqualizer",
           "[redacted:email]": "stremtec",
           "[redacted:email]": "solaitken",
    • tests/tools/test_skill_manager_tool.py
      @@ -957,6 +957,9 @@ def test_background_review_unpinned_skill_not_blocked_by_pin_guard(self, tmp_pat
                   _create_skill("my-skill", VALID_SKILL_CONTENT)
                   token = [redacted:secret-assignment]
                   try:
      +                from tools.skill_manager_tool import mark_background_review_skill_read
      +
      +                mark_background_review_skill_read(tmp_path / "my-skill" / "SKILL.md")
                       with patch(
                           "tools.skill_usage.get_record",
                           side_effect=lambda n: {"pinned": False},
      @@ -1173,6 +1176,7 @@ def _curator_pass(tmp_path, *, monkeypatch):
           skills_root.mkdir(parents=True, exist_ok=True)
           monkeypatch.setenv("HERMES_HOME", str(hermes_home))
           with patch("tools.skill_manager_tool.SKILLS_DIR", skills_root), \
      +         patch("tools.skills_tool.SKILLS_DIR", skills_root), \
                patch("agent.skill_utils.get_all_skills_dirs", return_value=[skills_root]), \
                patch("tools.skill_provenance.is_background_review", return_value=True):
               yield skills_root
      @@ -1281,3 +1285,66 @@ def test_dispatcher_preserves_usage_record_on_curator_archive(self, tmp_path, mo
                   rec = skill_usage.get_record("narrow")
               # Record kept (not forgotten) and marked archived.
               assert rec.get("state") == skill_usage.STATE_ARCHIVED
      +
      +    def test_background_review_patch_requires_skill_view_first(self, tmp_path, monkeypatch):
      +        from tools.skills_tool import skill_view
      +        from tools.skill_manager_tool import _reset_background_review_read_marks
      +
      +        _reset_background_review_read_marks()
      +        with _curator_pass(tmp_path, monkeypatch=monkeypatch):
      +            _create_skill("reviewed", _skill_content("reviewed"))
      +
      +            blocked = json.loads(skill_manage(
      +                action="patch",
      +                name="reviewed",
      +                old_string="Step 1: Do the thing.",
      +                new_string="Step 1: Do the thing safely.",
      +            ))
      +            assert blocked["success"] is False
      +            assert blocked.get("_read_before_write_required") is True
      +
      +            viewed = json.loads(skill_view("reviewed"))
      +            assert viewed["success"] is True
      +
      +            allowed = json.loads(skill_manage(
      +                action="patch",
      +                name="reviewed",
      +                old_string="Step 1: Do the thing.",
      +                new_string="Step 1: Do the thing safely.",
      +            ))
      +            assert allowed["success"] is True, allowed
      +
      +        _reset_background_review_read_marks()
      +
      +    def test_background_review_support_file_overwrite_requires_that_file_read(self, tmp_path, monkeypatch):
      +        from tools.skills_tool import skill_view
      +        from tools.skill_manager_tool import _reset_background_review_read_marks
      +
      +        _reset_background_review_read_marks()
      +        with _curator_pass(tmp_path, monkeypatch=monkeypatch):
      +            _create_skill("reviewed", _skill_content("reviewed"))
      +            ref = tmp_path / ".hermes" / "skills" / "reviewed" / "references"
      +            ref.mkdir()
      +            (ref / "workflow.md").write_text("old workflow\n", encoding="utf-8")
      +
      +            # Reading SKILL.md does not authorize overwriting a linked file.
      +            assert json.loads(skill_view("reviewed"))["success"] is True
      +            blocked = json.loads(skill_manage(
      +                action="write_file",
      +                name="reviewed",
      +                file_path="references/workflow.md",
      +                file_content="new workflow\n",
      +            ))
      +            assert blocked["success"] is False
      +            assert blocked.get("_read_before_write_required") is True
      +
      +            assert json.loads(skill_view("reviewed", "references/workflow.md"))["success"] is True
      +            allowed = json.loads(skill_manage(
      +                action="write_file",
      +                name="reviewed",
      +                file_path="references/workflow.md",
      +                file_content="new workflow\n",
      +            ))
      +            assert allowed["success"] is True, allowed
      +
      +        _reset_background_review_read_marks()
    • tools/skill_manager_tool.py
      @@ -38,15 +38,58 @@
       import re
       import shutil
       import tempfile
      +import contextvars as _ctxvars
       from pathlib import Path
      -from hermes_constants import get_hermes_home, display_hermes_home
      -from typing import Dict, Any, List, Optional, Tuple
      +from typing import Any, Dict, List, Optional, Tuple
       
      +from hermes_constants import get_hermes_home, display_hermes_home
       from utils import atomic_replace, is_truthy_value
       from hermes_cli.config import cfg_get
       
       logger = logging.getLogger(__name__)
       
      +_background_review_read_paths: "_ctxvars.ContextVar[frozenset[str]]" = _ctxvars.ContextVar(
      +    "background_review_read_paths", default=frozenset()
      +)
      +
      +
      +def mark_background_review_skill_read(path: Path) -> None:
      +    """Record that the active background-review fork has read a skill file.
      +
      +    The autonomous review fork is allowed to evolve skills, but it must not
      +    patch or rewrite content it has only inferred from the transcript.  The
      +    skill_view tool calls this after returning file content to the model; write
      +    paths below require the corresponding target path to be present when the
      +    current origin is ``background_review``.
      +    """
      +    try:
      +        from tools.skill_provenance import is_background_review
      +        if not is_background_review():
      +            return
      +    except Exception:
      +        return
      +
      +    try:
      +        resolved = str(path.resolve())
      +    except Exception:
      +        resolved = str(path)
      +    current = set(_background_review_read_paths.get())
      +    current.add(resolved)
      +    _background_review_read_paths.set(frozenset(current))
      +
      +
      +def _background_review_has_read(path: Path) -> bool:
      +    try:
      +        resolved = str(path.resolve())
      +    except Exception:
      +        resolved = str(path)
      +    return resolved in _background_review_read_paths.get()
      +
      +
      +def _reset_background_review_read_marks() -> None:
      +    """Test helper: clear read-before-write marks for the current context."""
      +    _background_review_read_paths.set(frozenset())
      +
       # Import security scanner — external hub installs always get scanned;
       # agent-created skills only get scanned when skills.guard_agent_created is on.
       try:
      @@ -320,6 +363,36 @@ def _background_review_write_guard(
           return None
       
       
      +def _background_review_read_before_write_guard(
      +    name: str,
      +    target: Path,
      +    action: str,
      +    file_label: str,
      +) -> Optional[Dict[str, Any]]:
      +    """Require review forks to load the exact target before mutating it."""
      +    try:
      +        from tools.skill_provenance import is_background_review
      +        if not is_background_review():
      +            return None
      +    except Exception:
      +        return None
      +
      +    if _background_review_has_read(target):
      +        return None
      +
      +    return {
      +        "success": False,
      +        "error": (
      +            f"Refusing background curator {action} for skill '{name}': "
      +            f"the current {file_label} content has not been loaded in this "
      +            "review turn. Call skill_view(name) for SKILL.md, or "
      +            "skill_view(name, file_path=...) for a supporting file, then "
      +            "retry the write using the content just returned."
      +        ),
      +        "_read_before_write_required": True,
      +    }
      +
      +
       def _background_review_preflight(action: str, name: str) -> Optional[Dict[str, Any]]:
           if action not in {"edit", "patch", "delete", "write_file", "remove_file"}:
               return None
      @@ -786,6 +859,12 @@ def _edit_skill(name: str, content: str) -> Dict[str, Any]:
               return guard
       
           skill_md = existing["path"] / "SKILL.md"
      +    read_guard = _background_review_read_before_write_guard(
      +        name, skill_md, "edit", "SKILL.md"
      +    )
      +    if read_guard:
      +        return read_guard
      +
           # Back up original content for rollback
           original_content = skill_md.read_text(encoding="utf-8") if skill_md.exists() else None
           _atomic_write_text(skill_md, content)
      @@ -849,6 +928,7 @@ def _patch_skill(
               target, err = _resolve_skill_target(skill_dir, file_path)
               if err:
                   return {"success": False, "error": err}
      +        assert target is not None
           else:
               # Patching SKILL.md
               target = skill_dir / "SKILL.md"
      @@ -856,6 +936,15 @@ def _patch_skill(
           if not target.exists():
               return {"success": False, "error": f"File not found: {target.relative_to(skill_dir)}"}
       
      +    read_guard = _background_review_read_before_write_guard(
      +        name,
      +        target,
      +        "patch",
      +        "SKILL.md" if not file_path else file_path,
      +    )
      +    if read_guard:
      +        return read_guard
      +
           content = target.read_text(encoding="utf-8")
       
           # 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]:
           target, err = _resolve_skill_target(existing["path"], file_path)
           if err:
               return {"success": False, "error": err}
      +    assert target is not None
      +    if target.exists():
      +        read_guard = _background_review_read_before_write_guard(
      +            name, target, "write_file", file_path
      +        )
      +        if read_guard:
      +            return read_guard
           target.parent.mkdir(parents=True, exist_ok=True)
           # Back up for rollback
           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]:
           target, err = _resolve_skill_target(skill_dir, file_path)
           if err:
               return {"success": False, "error": err}
      +    assert target is not None
           if not target.exists():
               # List what's actually there for the model to see
               available = []
      @@ -1111,6 +1208,12 @@ def _remove_file(name: str, file_path: str) -> Dict[str, Any]:
                   "available_files": available if available else None,
               }
       
      +    read_guard = _background_review_read_before_write_guard(
      +        name, target, "remove_file", file_path
      +    )
      +    if read_guard:
      +        return read_guard
      +
           target.unlink()
       
           # Clean up empty subdirectories
    • tools/skills_tool.py
      @@ -1281,6 +1281,17 @@ def _record(sd: Optional[Path], smd: Path) -> None:
                           ensure_ascii=False,
                       )
       
      +            try:
      +                from tools.skill_manager_tool import mark_background_review_skill_read
      +
      +                mark_background_review_skill_read(target_file)
      +            except Exception:
      +                logger.debug(
      +                    "Could not record background-review skill read for %s",
      +                    target_file,
      +                    exc_info=True,
      +                )
      +
                   return json.dumps(
                       {
                           "success": True,
      @@ -1484,6 +1495,17 @@ def _record(sd: Optional[Path], smd: Path) -> None:
               if capture_result["gateway_setup_hint"]:
                   result["gateway_setup_hint"] = capture_result["gateway_setup_hint"]
       
      +        try:
      +            from tools.skill_manager_tool import mark_background_review_skill_read
      +
      +            mark_background_review_skill_read(skill_md)
      +        except Exception:
      +            logger.debug(
      +                "Could not record background-review skill read for %s",
      +                skill_md,
      +                exc_info=True,
      +            )
      +
               if setup_needed:
                   missing_items = [
                       f"env ${env_name}" for env_name in remaining_missing_required_envs
  • #94530 fix(background-review): curator no longer refused for skipping the pre-read it was never told about (#62397, direction from #60331)

    merged to main

    files: agent/background_review.py, tests/run_agent/test_review_prompt_class_first.py

  • #97451 fix(skill_manage): batch failures keep the failing op's file_preview — models no longer recover blind

    merged to main

    files: tools/skill_manager_tool.py

  • #97650 fix(skill_manage): missing-old_string errors teach recovery instead of dead-ending into write_file clobbers (#33064, salvage #63879)

    merged to main

    files: tests/tools/test_skill_manager_tool.py, tools/skill_manager_tool.py

  • #98254 Background review lands skill patches again — read tools whitelisted, cache parity intact (#61521, #39996)

    merged to main

    files: agent/background_review.py, tests/run_agent/test_background_review_toolset_restriction.py, tools/file_tools.py

  • #98278 Background review accepts profile-configured extra tools via opt-in whitelist (closes #44672, salvage #82146)

    merged to main

    files: agent/background_review.py, contributors/emails/[redacted:email], tests/run_agent/test_background_review_toolset_restriction.py, website/docs/user-guide/features/memory.md

17 PRs closed without merging

From the close comments: done elsewhere 8 · superseded by another PR 3 · duplicate 2 · merged via another change 1 · partial fix 1 · other 2

  • #39997 fix(agent): narrow background-review tool schema for local providers · duplicate → #39996, #39997
  • #40007 fix(agent): allow background review to read files · other
  • #40060 fix(background_review): add read_file to review fork tool whitelist · done elsewhere → 1ee3035, #98254, #40003
  • #41469 fix: grant background review agent read_file access for skill patching · other → 20871c1
  • #45892 fix(agent): allow read-only file tools in background review whitelist · done elsewhere → 1ee3035, #98254, #40060, #46049
Show 12 more
  • #46049 fix(agent): allow read-only file tools in cron background review · done elsewhere → 1ee3035, #98254, #40060
  • #55657 fix(skills): require review forks to read before writing skills · merged via another change → 20871c1, #55906
  • #55665 fix(tools): guard against catastrophic content loss in background review patches · superseded by another PR → #55906, #55647
  • #59175 [codex] Fix background review skill read-before-write · superseded by another PR → #75020
  • #60331 fix(review): teach skill-review prompts the v2026.7.1 read-before-write guard · done elsewhere → a70d2ff, #94530
  • #61529 fix(agent): use restricted toolset for background review fork · done elsewhere → 1ee3035, #61521, #98254, #39997
  • #62414 fix(review): teach skill-review prompts the v2026.7.1 read-before-write guard · done elsewhere → a70d2ff, #94530, #60331
  • #62428 fix(agent): instruct background review fork to call skill_view before patching skills (#62397) · done elsewhere
  • #62664 fix(agent): document read-before-write handshake in background-review prompt (#62397) · duplicate → #60331, #55906, #62397, #62428, #62414, #62400
  • #63433 fix(curator): add explicit skill_view-before-skill_manage instruction · superseded by another PR → #61024
  • #63991 fix(tools): prevent curator infinite loop on skill_manage patch errors (#63964) · done elsewhere → #97650, #63879, #97451
  • #65803 fix(agent): steer background review to permitted tools · partial fix → 802d74a, #25434, #29704, #64379, #61521, #65803, #17276, #18973, #20674, #25330, #25427, #25322, #26850, #27422, #27510, #29568, #29567, #30532, #36967, #36995, #40007, #40003, #40060, #45014, #45892, #45902, #45877, #46049, #46684, #61529, #61618, #1, #15803, #14532, #6, #9, #374151, #4338
Closed issues · 9
  • Closed#39996[Bug]: background-review fork advertises the full tool schema to LOCAL endpoints, making weak local models thrash the deny-wall
  • Closed#40003[Bug]: background-review fork can't read an external file — model calls skill_manage(action="read_file") and gets "Unknown action"
  • Closed#41465[Bug]: Background review skill creation fails — tool whitelist blocks read_file
  • Closed#44672Background self-review: make the tool whitelist configurable (allow MCP/memory-provider writes)
  • Closed#45877Cron background review blocks read-only tools (read_file, search_files)
  • Closed#55647background-review: skill_manage patches hallucinate existing skill content; review fork writes without read-before-write invariant
  • Closed#61521background_review fork advertises the parent's full toolset but only permits skills+memory → per-turn tool-denial storm starves the self-improvement loop
  • Closed#62397Background review fork can't patch skills: prompt never calls skill_view, guard refuses the write (read-before-write mismatch)
  • Closed#63388[Bug] Curator/skill_manage fails in background: "SKILL.md content has not been loaded in this review turn"
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.05: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#55906, #94530, #97451, #97650, #98254, #98278
Closed issues
9
Attempts
17 PRs closed without merging
Fix link checked
30 Aug 2026

For your agent

The same facts, typed.