diff --git a/agent/curator.py b/agent/curator.py index 9668e76a46..c08d249612 100644 --- a/agent/curator.py +++ b/agent/curator.py @@ -539,6 +539,13 @@ CURATOR_REVIEW_PROMPT = ( "merges.\n\n" "Your toolset:\n" " - skills_list, skill_view — read the current landscape\n" + " READ BEFORE WRITE — enforced, not advisory. Before skill_manage " + "action=patch, action=edit, action=write_file on a file that already " + "exists, or action=remove_file, call skill_view on that SAME target in " + "this review turn — skill_view(name) for SKILL.md, " + "skill_view(name, file_path=...) for a supporting file — and build the " + "write from the content it just returned. A write without that read is " + "REFUSED and nothing is saved.\n" " - skill_manage action=patch — add sections to the umbrella\n" " - skill_manage action=create — create a new umbrella SKILL.md\n" " - skill_manage action=write_file — add a references/, templates/, " diff --git a/tests/agent/test_curator.py b/tests/agent/test_curator.py index f501c55b5f..bf448b21b6 100644 --- a/tests/agent/test_curator.py +++ b/tests/agent/test_curator.py @@ -525,6 +525,42 @@ def test_curator_does_not_instruct_model_to_pin(): +def test_curator_prompt_covers_every_read_before_write_guarded_action(): + """The review prompt must name every action the read-before-write guard + protects. + + ``_background_review_read_before_write_guard`` refuses a write when the + target was not loaded via ``skill_view`` in the same review turn. The + curator's forked reviewer only knows to do that if the prompt says so — + a guard the prompt never mentions is a silently jammed write channel, + not a safety net. This test fails if a new guarded action is added to + ``skill_manager_tool`` without teaching the prompt about it. + """ + import inspect + import re + + from agent.curator import CURATOR_REVIEW_PROMPT + from tools import skill_manager_tool + + known_actions = { + "create", "edit", "patch", "write_file", "remove_file", "delete", + } + source = inspect.getsource(skill_manager_tool) + guarded = set() + for call in source.split("_background_review_read_before_write_guard(")[1:]: + guarded |= known_actions.intersection(re.findall(r'"([^"]+)"', call[:200])) + + assert guarded, "no guarded actions found — did the guard get renamed?" + + prompt = CURATOR_REVIEW_PROMPT + assert "skill_view" in prompt + missing = [a for a in sorted(guarded) if f"action={a}" not in prompt] + assert not missing, ( + "read-before-write guards these actions but the curator prompt never " + f"tells the reviewer to read first for: {missing}" + ) + + def test_cli_pin_refuses_bundled_skill(curator_env, capsys): from hermes_cli import curator as cli skills_dir = curator_env["home"] / "skills"