From a2abc6650877a558938ad619326ee738de355380 Mon Sep 17 00:00:00 2001 From: Mickael Farina Date: Fri, 24 Jul 2026 20:10:06 +0200 Subject: [PATCH] =?UTF-8?q?fix(standing=5Frules):=20a=20mention=20is=20not?= =?UTF-8?q?=20a=20command=20=E2=80=94=20stop=20hijacking=20the=20turn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found while verifying the premise check end-to-end. A message quoting "In that cleanup process, was there ever a rule you had to delete?" fired the standing_rules skill, which tried to remove a rule named "you". The turn never reached the LLM, so the reply was a skill error — and neither the claim check nor the premise check ran, because both live on the LLM path. Two causes: * "standing rule", "standing rules" and "my rules" were bare TRIGGERS, so any message discussing standing rules matched. * _REMOVE / _LIST / _CLEAR were unanchored, so any sentence CONTAINING delete/list/clear near the word "rule" read as a command. Triggers are now commands only, and the regexes are anchored to the start of the message. A command is what a message opens with, not what it mentions. Same class as the file_ops chat-hang (#282). Verified: all four real commands still dispatch and execute; six mention-shaped sentences — including the exact quoted question — dispatch to nothing, and a mention can no longer delete a rule. Co-Authored-By: Claude Opus 4.8 --- skills/.manifest.json | 2 +- skills/standing_rules.py | 28 +++++++++++++++------- tests/test_standing_rules.py | 45 ++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 9 deletions(-) diff --git a/skills/.manifest.json b/skills/.manifest.json index 300d35d..819ed9b 100644 --- a/skills/.manifest.json +++ b/skills/.manifest.json @@ -79,7 +79,7 @@ "self_improve.py": "74c8b9cb0ce81f8600d35e72b73e543bdeccb3c8e5a4cdc79ddb4df5282c1846", "shift_report.py": "2c4bc669dec391f18329e0fe7b955c6b911322404e7e627e102776c22b448da2", "skill_forge.py": "07ea8fb2d026eca050154e5e60ea1968e77bc2d7786958d60f6e1a6d50f07182", - "standing_rules.py": "9f7f4a2af7cf8fbe11aaa11440c61eeab299e28da0e3bb0d7a52703787e22ee1", + "standing_rules.py": "1a3faa0da2213d5dba91625a34c8485190363eeef330b1ead23a0ca9bc0e45ea", "stuck.py": "dfddfe4dfa1a9d5c017f53e53033a7eb33114a517cd6fa937d9d44e65083f1c9", "system_info.py": "4cc32e0ad9cb81f34309734ef3388f7cde63407de9b23bb0cd589571a4c43ecb", "terminal.py": "1fe6c1cd1241ea6d328401f1c0fb4c362482e3ec2a61a4f7592b2d4822cd51c1", diff --git a/skills/standing_rules.py b/skills/standing_rules.py index 5a4e07b..ee42bcf 100644 --- a/skills/standing_rules.py +++ b/skills/standing_rules.py @@ -19,24 +19,36 @@ "reply, in every session. Persisted to disk, not just remembered in chat." ) SKILL_MCP_EXPOSE = True +# Commands only — never bare mentions. "standing rule(s)" and "my rules" used to +# be triggers, so merely DISCUSSING standing rules hijacked the turn: a message +# quoting "…was there ever a rule you had to delete?" fired this skill, which +# then tried to remove a rule named "you" and the reply never reached the LLM. +# Same class as the file_ops chat-hang (#282): a mention is not an instruction. SKILL_TRIGGERS = [ "add a standing rule", "add standing rule", - "standing rule", - "standing rules", - "my rules", - "always remember to", - "from now on always", + "remove standing rule", + "delete standing rule", + "list standing rules", + "show standing rules", + "show my standing rules", + "what are my standing rules", + "clear standing rules", ] _ADD = re.compile( r"^(?:add\s+(?:a\s+)?standing\s+rule|standing\s+rule|from\s+now\s+on\s+always|" r"always\s+remember\s+to)\b[:\s]*(.+)$", re.I | re.S) +# All anchored to the START of the message. Unanchored, any sentence CONTAINING +# these words matched — "was there ever a rule you had to delete?" read as a +# delete command. A command is what the message opens with, not what it mentions. _REMOVE = re.compile( - r"\b(?:remove|delete|drop|forget)\b.*\b(?:standing\s+)?rule\b\s*#?\s*(\w+)?", re.I) + r"^\s*(?:remove|delete|drop|forget)\s+(?:the\s+)?(?:standing\s+)?rule\s*#?\s*(\w+)", + re.I) _LIST = re.compile( - r"\b(?:list|show|what(?:'s| are)|see)\b.*\b(?:standing\s+)?rules?\b", re.I) -_CLEAR = re.compile(r"\b(?:clear|wipe|reset)\b.*\b(?:standing\s+)?rules\b", re.I) + r"^\s*(?:list|show|what(?:'s| are)|see)\b[^.?!]{0,20}\b(?:standing\s+)?rules?\b", re.I) +_CLEAR = re.compile( + r"^\s*(?:clear|wipe|reset)\s+(?:all\s+)?(?:my\s+)?(?:standing\s+)?rules\b", re.I) def _render(rules): diff --git a/tests/test_standing_rules.py b/tests/test_standing_rules.py index 64e19f9..4f5c933 100644 --- a/tests/test_standing_rules.py +++ b/tests/test_standing_rules.py @@ -187,3 +187,48 @@ def test_listing_labels_injection_honestly(monkeypatch, tmp_path): assert "sent to the model" in out assert "counts INJECTION" in out assert "fired" not in out.lower(), "must not claim the rule 'fired'" + + +# ── mentions must not hijack the turn ───────────────────────────────────────── +# "standing rule(s)" and "my rules" were bare triggers, so merely DISCUSSING +# standing rules fired the skill. Quoting "…was there ever a rule you had to +# delete?" matched the unanchored remove-regex, the skill tried to remove a rule +# named "you", and the turn never reached the LLM — so the reply was a skill +# error and the claim/premise checks never ran. Same class as the file_ops +# chat-hang (#282): a mention is not an instruction. + +@pytest.mark.parametrize("text", [ + "was there ever a rule you had to delete?", + "In that cleanup process, was there ever a standing rule you thought was important?", + "I like the idea of standing rules for agents.", + "You mentioned you delete the rules that never fire.", + "Rules you never invoke are just noise you pay for on every message.", + "he ends each session by listing which standing rules actually fired", +]) +def test_mentions_do_not_dispatch(text): + import codec_dispatch as cd + cd.load_skills() + hit = cd.check_skill(text) + assert (hit or {}).get("name") != "standing_rules", f"hijacked by: {text!r}" + + +@pytest.mark.parametrize("text", [ + "add a standing rule: be brief", + "show my standing rules", + "list standing rules", + "remove standing rule 2", +]) +def test_real_commands_still_dispatch(text): + import codec_dispatch as cd + cd.load_skills() + hit = cd.check_skill(text) + assert (hit or {}).get("name") == "standing_rules", f"command missed: {text!r}" + + +def test_remove_regex_requires_a_leading_command(monkeypatch, tmp_path): + s = _skill(monkeypatch, tmp_path) + s.run("add a standing rule: keep me") + # a question that merely mentions deleting must not remove anything + s.run("was there ever a rule you had to delete?") + import codec_standing_rules as mod + assert len(mod.list_rules()) == 1, "a mention must never delete a rule"