refactor(memory,skills): replace tri-state write_mode with boolean write_approval (default off) (#43354)
The shipped tri-state write_mode (on|off|approve) conflated two concepts —
whether writes are enabled and whether they're gated — so 'on' (writes flow
freely, gate inactive) read like 'gating is on'. Replace it with a single
clear boolean gate that defaults off.
memory.write_approval / skills.write_approval:
false (default) — write freely; the approval gate is off (pre-gate behaviour)
true — require approval: memory foreground prompts inline, memory
background-review + all skill writes stage for review
The old 'off = block all writes' mode is dropped; memory_enabled: false already
disables memory entirely, so a third 'block' state was redundant.
- tools/write_approval.py: get_write_mode/MODE_* → write_approval_enabled() bool;
evaluate_gate() loses the config-driven 'blocked' path (blocked now only comes
from an interactive user denial).
- tools/memory_tool.py, tools/skill_manager_tool.py: comment + behaviour follow.
- hermes_cli/config.py: memory/skills write_mode → write_approval (False);
_config_version 28→29 with a 28→29 migration that renames any persisted
write_mode (approve→true, on/off/unset→false) and drops the old key.
- slash commands: '/memory|/skills mode <on|off|approve>' → 'approval <on|off>'
('mode' kept as a back-compat alias); set_mode_fn callback now takes a bool.
- write_approval_commands.py, cli_commands_mixin.py, gateway/slash_commands.py,
commands.py: handlers + registry args/subcommands updated.
- docs + tests rewritten for the boolean model; added migration tests.
This commit is contained in:
@@ -1075,3 +1075,50 @@ class TestEnvWriteDenylist:
|
||||
# But the write path still refuses to update it
|
||||
with pytest.raises(ValueError, match="denylist"):
|
||||
save_env_value("LD_PRELOAD", "/tmp/evil.so")
|
||||
|
||||
|
||||
class TestWriteApprovalMigration:
|
||||
"""Version 28→29 renames memory/skills write_mode → write_approval (bool).
|
||||
|
||||
Only an explicit ``approve`` carried gating intent and maps to ``True``;
|
||||
``on``/``off``/unset map to ``False`` (gate off). The old ``write_mode`` key
|
||||
is removed. Only a persisted key is rewritten — never invented.
|
||||
"""
|
||||
|
||||
def _write(self, tmp_path, body: str):
|
||||
(tmp_path / "config.yaml").write_text(body)
|
||||
|
||||
def test_approve_maps_to_true(self, tmp_path):
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
self._write(tmp_path,
|
||||
"_config_version: 28\nmemory:\n write_mode: approve\n"
|
||||
"skills:\n write_mode: approve\n")
|
||||
migrate_config(interactive=False, quiet=True)
|
||||
raw = yaml.safe_load((tmp_path / "config.yaml").read_text())
|
||||
assert raw["memory"]["write_approval"] is True
|
||||
assert raw["skills"]["write_approval"] is True
|
||||
assert "write_mode" not in raw["memory"]
|
||||
assert "write_mode" not in raw["skills"]
|
||||
|
||||
def test_on_and_off_map_to_false(self, tmp_path):
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
# YAML 1.1 parses bare on/off as bools — write_mode could be either
|
||||
# the string or the bool; both legacy "not gating" values → False.
|
||||
self._write(tmp_path,
|
||||
"_config_version: 28\nmemory:\n write_mode: 'on'\n"
|
||||
"skills:\n write_mode: 'off'\n")
|
||||
migrate_config(interactive=False, quiet=True)
|
||||
raw = yaml.safe_load((tmp_path / "config.yaml").read_text())
|
||||
assert raw["memory"]["write_approval"] is False
|
||||
assert raw["skills"]["write_approval"] is False
|
||||
|
||||
def test_unset_key_defaults_to_false(self, tmp_path):
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
self._write(tmp_path, "_config_version: 28\nmemory:\n memory_enabled: true\n")
|
||||
migrate_config(interactive=False, quiet=True)
|
||||
raw = yaml.safe_load((tmp_path / "config.yaml").read_text())
|
||||
# No write_mode was persisted, so the rename is a no-op; the missing-
|
||||
# field pass then seeds the default (False = gate off). Either way the
|
||||
# gate ends up off and there's no leftover write_mode key.
|
||||
assert raw["memory"].get("write_approval", False) is False
|
||||
assert "write_mode" not in raw.get("memory", {})
|
||||
|
||||
Reference in New Issue
Block a user