Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions cli-config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,16 @@ skills:
# Set to 0 to disable.
creation_nudge_interval: 15

# Make all skills read-only at runtime. When true, Hermes will never create,
# edit, patch, delete, or otherwise write skill files from any code path —
# the skill_manage tool, the background self-improvement review loop, and the
# dashboard learn flow are all blocked from mutating skills. Listing, viewing,
# and *using* skills (running their scripts, following their procedures)
# continue to work. Enable this when skills are centrally managed and mounted
# into the Hermes container as a read-only volume so runtime changes can't
# pollute the shared set across pods. Default: false.
# read_only: true

# External skill directories — share skills across tools/agents without
# copying them into ~/.hermes/skills/. Each path is expanded (~ and ${VAR})
# and resolved to an absolute path. External dirs are read-only: skill
Expand Down
140 changes: 114 additions & 26 deletions tests/tools/test_skill_manager_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -1316,35 +1316,123 @@ def test_background_review_patch_requires_skill_view_first(self, tmp_path, monke

_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(

class TestSkillsReadOnly:
"""#64926: skills.read_only must block every runtime write action while
leaving list/view/use paths untouched."""

def test_read_only_blocks_create(self, tmp_path, monkeypatch):
monkeypatch.setattr(
"tools.skill_manager_tool._skills_read_only", lambda: True
)
with _skill_dir(tmp_path):
raw = skill_manage(
action="create", name="blocked", content=VALID_SKILL_CONTENT
)
result = json.loads(raw)
assert result["success"] is False
assert "read_only" in result["error"]
assert not (tmp_path / "blocked" / "SKILL.md").exists()

def test_read_only_blocks_edit(self, tmp_path, monkeypatch):
# Create with read_only off, then confirm edit is blocked.
with _skill_dir(tmp_path):
monkeypatch.setattr(
"tools.skill_manager_tool._skills_read_only", lambda: False
)
skill_manage(action="create", name="s", content=VALID_SKILL_CONTENT)
monkeypatch.setattr(
"tools.skill_manager_tool._skills_read_only", lambda: True
)
raw = skill_manage(
action="edit", name="s", content=VALID_SKILL_CONTENT + "\n# x"
)
result = json.loads(raw)
assert result["success"] is False
assert "read_only" in result["error"]

def test_read_only_blocks_patch(self, tmp_path, monkeypatch):
with _skill_dir(tmp_path):
monkeypatch.setattr(
"tools.skill_manager_tool._skills_read_only", lambda: False
)
skill_manage(action="create", name="s", content=VALID_SKILL_CONTENT)
monkeypatch.setattr(
"tools.skill_manager_tool._skills_read_only", lambda: True
)
raw = skill_manage(action="patch", name="s", old_string="test", new_string="x")
result = json.loads(raw)
assert result["success"] is False
assert "read_only" in result["error"]

def test_read_only_blocks_delete(self, tmp_path, monkeypatch):
# Create the skill first (read_only off), then flip read_only on and
# confirm delete is blocked and the file survives.
with _skill_dir(tmp_path):
monkeypatch.setattr(
"tools.skill_manager_tool._skills_read_only", lambda: False
)
skill_manage(action="create", name="s", content=VALID_SKILL_CONTENT)
monkeypatch.setattr(
"tools.skill_manager_tool._skills_read_only", lambda: True
)
raw = skill_manage(action="delete", name="s")
result = json.loads(raw)
assert result["success"] is False
assert "read_only" in result["error"]
assert (tmp_path / "s" / "SKILL.md").exists()

def test_read_only_blocks_write_file(self, tmp_path, monkeypatch):
with _skill_dir(tmp_path):
monkeypatch.setattr(
"tools.skill_manager_tool._skills_read_only", lambda: False
)
skill_manage(action="create", name="s", content=VALID_SKILL_CONTENT)
monkeypatch.setattr(
"tools.skill_manager_tool._skills_read_only", lambda: True
)
raw = 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
name="s",
file_path="references/extra.md",
file_content="data",
)
result = json.loads(raw)
assert result["success"] is False
assert "read_only" in result["error"]

assert json.loads(skill_view("reviewed", "references/workflow.md"))["success"] is True
allowed = json.loads(skill_manage(
def test_read_only_blocks_remove_file(self, tmp_path, monkeypatch):
with _skill_dir(tmp_path):
monkeypatch.setattr(
"tools.skill_manager_tool._skills_read_only", lambda: False
)
skill_manage(action="create", name="s", content=VALID_SKILL_CONTENT)
skill_manage(
action="write_file",
name="reviewed",
file_path="references/workflow.md",
file_content="new workflow\n",
))
assert allowed["success"] is True, allowed
name="s",
file_path="references/extra.md",
file_content="data",
)
monkeypatch.setattr(
"tools.skill_manager_tool._skills_read_only", lambda: True
)
raw = skill_manage(action="remove_file", name="s", file_path="references/extra.md")
result = json.loads(raw)
assert result["success"] is False
assert "read_only" in result["error"]

def test_read_only_false_allows_write(self, tmp_path, monkeypatch):
"""When the flag is off (default) writes still succeed."""
monkeypatch.setattr(
"tools.skill_manager_tool._skills_read_only", lambda: False
)
with _skill_dir(tmp_path):
raw = skill_manage(
action="create", name="allowed", content=VALID_SKILL_CONTENT
)
result = json.loads(raw)
assert result["success"] is True
assert (tmp_path / "allowed" / "SKILL.md").exists()

_reset_background_review_read_marks()
39 changes: 39 additions & 0 deletions tools/skill_manager_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,28 @@ def _reset_background_review_read_marks() -> None:
_GUARD_AVAILABLE = False


def _skills_read_only() -> bool:
"""True when runtime Skill writes are disabled via ``skills.read_only``.

Platform operators who mount a centrally-managed skill set into the
Hermes container (read-only volume) use this to guarantee Hermes never
creates, edits, patches, or deletes skill files at runtime — from any
path (the ``skill_manage`` tool, the background self-improvement review,
or the dashboard learn flow). Listing, viewing, and *using* skills are
unaffected. See NousResearch/hermes-agent#64926.
"""
try:
from hermes_cli.config import load_config

cfg = load_config()
return is_truthy_value(
cfg_get(cfg, "skills", "read_only"),
default=False,
)
except Exception:
return False


def _guard_agent_created_enabled() -> bool:
"""Read skills.guard_agent_created from config (default False).

Expand Down Expand Up @@ -1334,6 +1356,23 @@ def skill_manage(

Returns JSON string with results.
"""
_WRITE_ACTIONS = frozenset(
{"create", "edit", "patch", "delete", "write_file", "remove_file"}
)
if action in _WRITE_ACTIONS and _skills_read_only():
return json.dumps(
{
"success": False,
"error": (
"Skill writes are disabled by platform policy "
"(skills.read_only is true). Skills are read-only at runtime: "
"create/edit/patch/delete/write_file/remove_file are all blocked. "
"Listing, viewing, and using skills still work."
),
},
ensure_ascii=False,
)

preflight = _background_review_preflight(action, name)
if preflight is not None:
return json.dumps(preflight, ensure_ascii=False)
Expand Down
Loading