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:
Teknium
2026-06-09 23:21:14 -07:00
committed by GitHub
parent 9ca9697342
commit 095f526b11
11 changed files with 296 additions and 187 deletions
+47
View File
@@ -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", {})