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
- 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.
Next step: read #55906, then check whether your report is one of the 4 still open.
Read #55906Pick up a Lab taskThese 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
-
#55906 fix(skills): require background-review forks to read before writing skills (#55647)
files:
agent/background_review.py,scripts/release.py,tests/tools/test_skill_manager_tool.py,tools/skill_manager_tool.py,tools/skills_tool.pyShow 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)
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
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)
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)
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)
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.00means 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