From d6ee035a68da0de231b61b3c9fd084ffd804e6f2 Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark" Date: Sat, 15 Aug 2026 04:52:05 -0500 Subject: [PATCH] feat(guard): require a minimum term overlap before preflight injects a memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The turn preflight injected the top-ranked note on ANY score > 0, so a one-word prompt like 'retry' pulled an entirely unrelated note into every session (observed fleet-wide; ranked 'best' is not the same as relevant). - retrieve.matched_terms(): distinct stemmed task-term overlap, sharing _tokens with overlap_score so 'matched' means the same thing everywhere - retrieve.preflight_min_terms(): threshold, default 2, env-overridable via OMIND_PREFLIGHT_MIN_TERMS (0 disables the filter) - guard.preflight_turn(): a weak match is treated like a miss — no injection, gate auto-clears (honors OMI_GATE_MISS_STRICT), logged as omi-gate-weak-match Ratio scores cannot gate this (a one-term task trivially scores 1.0), hence an absolute match count. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XRHMqf9JLj1P6Gbb9XpFsh --- src/omind/guard.py | 63 +++++++++----- src/omind/retrieve.py | 185 ++++++++++++++++++++++++++++++++++------- tests/test_guard.py | 90 +++++++++++++++----- tests/test_retrieve.py | 25 ++++++ 4 files changed, 293 insertions(+), 70 deletions(-) diff --git a/src/omind/guard.py b/src/omind/guard.py index 1034ec6..8057385 100644 --- a/src/omind/guard.py +++ b/src/omind/guard.py @@ -69,6 +69,7 @@ MISS_STRICT_ENV = "OMI_GATE_MISS_STRICT" #: Synthetic rule id for a preflight miss that auto-cleared the gate. GATE_NO_MATCH_RULE = "omi-gate-no-match" +GATE_WEAK_MATCH_RULE = "omi-gate-weak-match" GIT_RULES_NOTE = "Operational Rules - Git Repos and Secrets" GIT_RULES_MESSAGE = ( "ACTION BLOCKED. Next call OMI MCP `recall-note` with " @@ -360,9 +361,7 @@ def mark_consulted(session: str) -> None: _write_sentinel(session, data) -def record_consult( - session: str, *, kind: str, target: str, relevant: bool | None = None -) -> None: +def record_consult(session: str, *, kind: str, target: str, relevant: bool | None = None) -> None: """Append one OMI consult (note read / search) to the turn's sentinel with its relevance verdict (``None`` = not yet judged), and mark the gate consulted. Never raises.""" @@ -833,9 +832,7 @@ def _opt_in_satisfied(opt_in: str, command: str) -> bool: The optional ``env `` prefix must ITSELF be at command position — otherwise ``echo "use env OMI_SUDO_OK=1" && sudo …`` forged the opt-in from inside a string (the ``\\benv``-anywhere bug) and skipped a hard rule.""" - pattern = ( - r"(?:^|[;&|\n])[ \t]*(?:env[ \t]+)?" + re.escape(opt_in) + r"(?=\s|$)" - ) + pattern = r"(?:^|[;&|\n])[ \t]*(?:env[ \t]+)?" + re.escape(opt_in) + r"(?=\s|$)" return re.search(pattern, command) is not None @@ -1168,9 +1165,7 @@ def decide(action: dict[str, Any]) -> Verdict: if gate_paused(): return Verdict(allow=True) - if _is_global_config_mutation(action) and not _turn_has_explicit_global_auth( - action, session - ): + if _is_global_config_mutation(action) and not _turn_has_explicit_global_auth(action, session): return Verdict( allow=False, reason=f"omi-guard (hard): {GLOBAL_MUTATION_MESSAGE}", @@ -1296,11 +1291,7 @@ def check_action(action: dict[str, Any], omi_dir: Path | None = None) -> Verdict verdict = _note_rules_verdict(action, omi_dir) if verdict is None: verdict = decide(action) - if ( - not verdict.allow - and verdict.rule_id == "repo-work-read-git-rules" - and omi_dir is not None - ): + if not verdict.allow and verdict.rule_id == "repo-work-read-git-rules" and omi_dir is not None: # #241: place the governing rule text adjacent to the action it blocks. # The demand sentence stays first — the recall ceremony still runs and # feeds consult telemetry — but the rule itself rides along, because an @@ -1310,9 +1301,7 @@ def check_action(action: dict[str, Any], omi_dir: Path | None = None) -> Verdict if excerpt: verdict = Verdict( allow=False, - reason=( - f"{verdict.reason}\n\n--- Governing memory (excerpt) ---\n{excerpt}" - ), + reason=(f"{verdict.reason}\n\n--- Governing memory (excerpt) ---\n{excerpt}"), rule_id=verdict.rule_id, ) if not verdict.allow and verdict.rule_id == "omi-gate" and omi_dir is not None: @@ -1528,6 +1517,37 @@ def preflight_turn(data: dict[str, Any], omi_dir: Path | None) -> str: filename, max_chars=ai_usage.policy(omi_dir).preflight_chars, ) + # #257: the ranking surfaces the best candidate even when "best" is a single + # shared word (a bare "retry" turn pulling an unrelated note). Require a + # minimum absolute term overlap before an unsolicited injection; a weak + # match is treated like a miss (auto-clear unless MISS_STRICT opts back in). + min_terms = retrieve.preflight_min_terms() + if min_terms: + haystack = " ".join(str(memory.get(key) or "") for key in ("title", "summary", "content")) + if retrieve.matched_terms(task, haystack) < min_terms: + if not _miss_strict(): + record_consult(session, kind="weak-match", target=filename, relevant=False) + compliance.log_event( + compliance.KIND_DECISION, + session=session, + tool="UserPromptSubmit", + rule_id=GATE_WEAK_MATCH_RULE, + severity="soft", + outcome="auto-clear", + detail=f"note={filename!r} task={task[:100]!r}", + ) + return ( + "OMI turn preflight found only a weak memory match (fewer " + f"than {min_terms} task terms shared) — not injecting it. " + "Consult gate cleared for this turn — proceeding without a " + f"forced read (set {MISS_STRICT_ENV}=1 to require one anyway)." + ) + return ( + "OMI turn preflight found no confident memory match. The consult " + "gate remains armed. Before any non-memory tool, call OMI MCP " + "`search-vault` with a focused query, then `recall-note` on one " + "result." + ) version = str(memory.get("version") or "") repeated = _injected_versions(session).get(filename) == version # #241: the summary-only optimization for repeated notes loses to attention @@ -1537,8 +1557,10 @@ def preflight_turn(data: dict[str, Any], omi_dir: Path | None) -> str: action_shaped = bool(_ACTION_TURN_RE.search(task)) summary = str(memory.get("summary") or "").strip() excerpt = str(memory.get("content") or "").strip() - content = summary if repeated and not action_shaped else "\n\n".join( - part for part in (summary, excerpt) if part and part != summary + content = ( + summary + if repeated and not action_shaped + else "\n\n".join(part for part in (summary, excerpt) if part and part != summary) ) if not content: content = str(memory.get("title") or filename) @@ -1554,8 +1576,7 @@ def preflight_turn(data: dict[str, Any], omi_dir: Path | None) -> str: ) + ". This is a standing operator instruction/memory relevant to this " "turn — apply it unless the user's current message explicitly " - "overrides it. Silence is not an override.\n\n" - + content + "overrides it. Silence is not an override.\n\n" + content ) context += _second_title_line(omi_dir, titles, filename) ai_usage.record_context(omi_dir, "recall", len(context), session_id=session) diff --git a/src/omind/retrieve.py b/src/omind/retrieve.py index d9cd160..0867815 100644 --- a/src/omind/retrieve.py +++ b/src/omind/retrieve.py @@ -21,6 +21,7 @@ import importlib import json +import os import re from pathlib import Path @@ -31,16 +32,79 @@ _STOPWORDS = frozenset( { # function words - "and", "are", "but", "for", "from", "has", "have", "how", "into", "its", - "that", "the", "their", "then", "there", "these", "this", "was", "were", - "what", "when", "which", "who", "why", "with", "you", "your", "our", "does", - "than", "them", "they", "here", "over", "out", + "and", + "are", + "but", + "for", + "from", + "has", + "have", + "how", + "into", + "its", + "that", + "the", + "their", + "then", + "there", + "these", + "this", + "was", + "were", + "what", + "when", + "which", + "who", + "why", + "with", + "you", + "your", + "our", + "does", + "than", + "them", + "they", + "here", + "over", + "out", # instruction filler / generic verbs that never carry the task's topic - "please", "before", "after", "again", "also", "just", "now", "more", - "most", "want", "wants", "need", "needs", "make", "makes", "made", "let", - "lets", "use", "uses", "used", "using", "can", "will", "would", "should", - "could", "must", "may", "might", "get", "gets", "got", "about", "any", - "all", "further", + "please", + "before", + "after", + "again", + "also", + "just", + "now", + "more", + "most", + "want", + "wants", + "need", + "needs", + "make", + "makes", + "made", + "let", + "lets", + "use", + "uses", + "used", + "using", + "can", + "will", + "would", + "should", + "could", + "must", + "may", + "might", + "get", + "gets", + "got", + "about", + "any", + "all", + "further", } ) @@ -55,14 +119,42 @@ _SUFFIXES: tuple[str, ...] = tuple( sorted( { - "ization", "isation", "ational", - "fulness", "iveness", "ousness", - "ation", "ition", "ement", - "ance", "ence", "able", "ible", - "ingly", "edly", "fully", - "tion", "sion", "ness", "ment", "ical", - "ing", "ies", "ied", "ity", "ive", "ous", "ant", "ent", - "er", "or", "al", "ed", "es", "ly", "s", + "ization", + "isation", + "ational", + "fulness", + "iveness", + "ousness", + "ation", + "ition", + "ement", + "ance", + "ence", + "able", + "ible", + "ingly", + "edly", + "fully", + "tion", + "sion", + "ness", + "ment", + "ical", + "ing", + "ies", + "ied", + "ity", + "ive", + "ous", + "ant", + "ent", + "er", + "or", + "al", + "ed", + "es", + "ly", + "s", }, key=len, reverse=True, @@ -74,9 +166,21 @@ #: heavily de-ranked unless the task is itself about credentials. _CREDENTIAL_TERMS = frozenset( { - "credential", "credentials", "secret", "secrets", "token", "tokens", - "password", "passwords", "passphrase", "auth", "apikey", "keyfile", - "keyring", "gpg", "pass", + "credential", + "credentials", + "secret", + "secrets", + "token", + "tokens", + "password", + "passwords", + "passphrase", + "auth", + "apikey", + "keyfile", + "keyring", + "gpg", + "pass", } ) @@ -117,11 +221,7 @@ def _stem(word: str) -> str: def _tokens(text: str) -> set[str]: - return { - _stem(w) - for w in _WORD_RE.findall(text.lower()) - if w not in _STOPWORDS and len(w) > 2 - } + return {_stem(w) for w in _WORD_RE.findall(text.lower()) if w not in _STOPWORDS and len(w) > 2} def overlap_score(task: str, text: str) -> float: @@ -137,6 +237,37 @@ def overlap_score(task: str, text: str) -> float: return len(task_terms & _tokens(text)) / len(task_terms) +#: Minimum DISTINCT meaningful task terms a note must share with the turn's +#: task before the proactive preflight injects it (``0`` disables the filter). +#: The ranking paths surface the best candidate even when "best" is one shared +#: word — a bare "retry" turn matching an unrelated note's title — which is the +#: right behavior for gate-deny *suggestions* but far too eager for unsolicited +#: per-turn injection. Ratio scores can't gate this (a one-term task trivially +#: scores 1.0), so the preflight requires an absolute match count instead. +PREFLIGHT_MIN_TERMS_ENV = "OMIND_PREFLIGHT_MIN_TERMS" +_PREFLIGHT_MIN_TERMS_DEFAULT = 2 + + +def matched_terms(task: str, text: str) -> int: + """How many distinct meaningful task terms appear in ``text``. + + Shares :func:`_tokens` (stopwords + stemming) with :func:`overlap_score`, + so "matched" means the same thing as everywhere else in retrieval. + """ + return len(_tokens(task) & _tokens(text)) + + +def preflight_min_terms() -> int: + """The preflight injection threshold (env-overridable, never negative).""" + raw = os.environ.get(PREFLIGHT_MIN_TERMS_ENV, "").strip() + if raw: + try: + return max(0, int(raw)) + except ValueError: + pass + return _PREFLIGHT_MIN_TERMS_DEFAULT + + def normalize_intent(text: str) -> str: """Strip command scaffolding from a gate-blocked action before it is scored as the turn's *pending intent* (#97). Drops a leading ``cd &&|;`` and @@ -199,9 +330,7 @@ def _indexed_titles( cred_files = set() if not task_is_cred: cred_files = { - row.filename - for row in rows - if _looks_credential(row.title, " ".join(row.tags)) + row.filename for row in rows if _looks_credential(row.title, " ".join(row.tags)) } titles = [ title_by_file[hit.filename] diff --git a/tests/test_guard.py b/tests/test_guard.py index 36ef465..a6a26ad 100644 --- a/tests/test_guard.py +++ b/tests/test_guard.py @@ -22,9 +22,7 @@ #: NOT on Windows, where Git Bash's CRLF/path quirks make the same script exit 1 and #: where the hook isn't the deployed form anyway. _HOOK_TESTABLE = ( - sys.platform != "win32" - and shutil.which("bash") is not None - and shutil.which("jq") is not None + sys.platform != "win32" and shutil.which("bash") is not None and shutil.which("jq") is not None ) @@ -84,9 +82,7 @@ def test_raw_sudo_blocked_but_fleet_sudo_and_opt_in_allowed() -> None: assert not verdict.allow assert verdict.rule_id == "sudo-use-fleet-sudo" # fleet-sudo is NOT caught by the sudo rule (the "-sudo" suffix is excluded) - assert guard.decide( - {"command": "fleet-sudo systemctl reload nginx", "session": "sSudo"} - ).allow + assert guard.decide({"command": "fleet-sudo systemctl reload nginx", "session": "sSudo"}).allow # a deliberate raw sudo opts in, like the Codeberg-mirror escape hatch assert guard.decide({"command": "OMI_SUDO_OK=1 sudo reboot", "session": "sSudo"}).allow guard.clear_gate("sSudo") @@ -174,9 +170,7 @@ def test_repo_work_requires_git_rules_note_and_freshness_check() -> None: assert compound.rule_id == "repo-work-fresh-base" # A standalone fetch establishes freshness for the separate next commit. - fresh = guard.decide( - {"tool": "Bash", "command": "git fetch --all --prune", "session": "repo"} - ) + fresh = guard.decide({"tool": "Bash", "command": "git fetch --all --prune", "session": "repo"}) assert fresh.allow assert guard.decide({"tool": "Bash", "command": "git commit -am x", "session": "repo"}).allow guard.clear_gate("repo") @@ -234,12 +228,11 @@ def test_non_repo_work_does_not_demand_freshness( guard.record_consult(session, kind="read", target="task memory", relevant=True) target = tmp_path / "notes.txt" - assert guard._repo_root_for_action( - {"tool": "Write", "file_path": str(target), "session": session} - ) is None - allowed = guard.decide( - {"tool": "Write", "file_path": str(target), "session": session} + assert ( + guard._repo_root_for_action({"tool": "Write", "file_path": str(target), "session": session}) + is None ) + allowed = guard.decide({"tool": "Write", "file_path": str(target), "session": session}) assert allowed.allow, allowed.rule_id guard.clear_gate(session) @@ -284,9 +277,7 @@ def test_freshness_gate_applies_only_to_commits(tmp_path: Path) -> None: guard.record_consult(session, kind="read", target=guard.GIT_RULES_NOTE, relevant=True) # Non-commit repo work on a stale base: allowed (rules-note satisfied, no fetch). - assert guard.decide( - {"tool": "Edit", "file_path": str(repo / "x.py"), "session": session} - ).allow + assert guard.decide({"tool": "Edit", "file_path": str(repo / "x.py"), "session": session}).allow assert guard.decide( {"tool": "Bash", "command": f"git -C {repo} push origin main", "session": session} ).allow @@ -544,6 +535,63 @@ def test_turn_preflight_with_empty_task_stays_strict(tmp_path: Path) -> None: assert not guard.consulted_this_turn("preflight-empty") +def test_turn_preflight_weak_match_auto_clears_without_injecting( + tmp_path: Path, +) -> None: + # A single shared term (here "retry") ranks the note as the best candidate, + # but one word is not evidence of relevance — no injection, gate cleared. + from omind.store import NoteFields, OmiStore + + omi = tmp_path / "OMI" + omi.mkdir() + OmiStore(omi).create_note( + NoteFields( + title="Ghidra decompiler retry budget", + summary="Retry the decompile with a doubled budget.", + details="GUI-only behaviour; headless precheck cannot drive it.", + ) + ) + context = guard.preflight_turn({"session_id": "preflight-weak", "prompt": "retry"}, omi) + assert "weak memory match" in context + assert "[[" not in context # nothing injected + assert guard.consulted_this_turn("preflight-weak") + events = compliance.read_events() + assert events[-1]["rule_id"] == guard.GATE_WEAK_MATCH_RULE + assert events[-1]["outcome"] == "auto-clear" + + +def test_turn_preflight_weak_match_stays_strict_when_opted_in( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from omind.store import NoteFields, OmiStore + + monkeypatch.setenv(guard.MISS_STRICT_ENV, "1") + omi = tmp_path / "OMI" + omi.mkdir() + OmiStore(omi).create_note( + NoteFields(title="Ghidra decompiler retry budget", summary="Retry logic.") + ) + context = guard.preflight_turn({"session_id": "preflight-weak-strict", "prompt": "retry"}, omi) + assert "search-vault" in context and "recall-note" in context + assert not guard.consulted_this_turn("preflight-weak-strict") + + +def test_turn_preflight_weak_match_filter_disabled_by_env( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from omind import retrieve + from omind.store import NoteFields, OmiStore + + monkeypatch.setenv(retrieve.PREFLIGHT_MIN_TERMS_ENV, "0") + omi = tmp_path / "OMI" + omi.mkdir() + OmiStore(omi).create_note( + NoteFields(title="Ghidra decompiler retry budget", summary="Retry logic.") + ) + context = guard.preflight_turn({"session_id": "preflight-weak-off", "prompt": "retry"}, omi) + assert "[[Ghidra decompiler retry budget]]" in context # legacy behavior + + def test_preflight_cli_emits_user_prompt_additional_context( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: @@ -1151,9 +1199,7 @@ def test_dash_c_parsing_edge_cases_fall_back_to_cwd( assert got == expected, command # Record and check sides resolve the SAME string for the same repo (#147) — # the marker is an exact string match, so this equality is load-bearing. - fetch_side = guard._repo_root_for_action( - {"tool": "Bash", "command": f"git -C {repo_b} fetch"} - ) + fetch_side = guard._repo_root_for_action({"tool": "Bash", "command": f"git -C {repo_b} fetch"}) commit_side = guard._repo_root_for_action( {"tool": "Bash", "command": f"git -C {repo_b} commit -m x"} ) @@ -1343,7 +1389,7 @@ def test_guard_status_flags_agent_writable_config(capsys: pytest.CaptureFixture[ def test_would_you_is_a_polite_imperative_not_a_capability_question() -> None: - """"Would you ...?" authorizes; "Can you ...?" still does not.""" + """ "Would you ...?" authorizes; "Can you ...?" still does not.""" allowed = guard.decide( { "tool": "Bash", @@ -1380,6 +1426,8 @@ def test_would_you_without_an_authorizing_verb_still_blocks_side_effects() -> No ) assert not blocked.allow guard.clear_gate("wouldneg") + + def test_guard_pause_is_capped(capsys: pytest.CaptureFixture[str]) -> None: """A week-long pause is a disable with extra steps: it silently masks the enforcement check for the duration. One box was found paused for 185h.""" diff --git a/tests/test_retrieve.py b/tests/test_retrieve.py index d0457c7..712d23c 100644 --- a/tests/test_retrieve.py +++ b/tests/test_retrieve.py @@ -148,3 +148,28 @@ def test_normalize_intent_lifts_path_heavy_pending_score() -> None: norm = retrieve.normalize_intent(pending) assert "prototype" not in norm and "corpus" not in norm # dir scaffolding gone assert retrieve.overlap_score(norm, consult) > retrieve.overlap_score(pending, consult) + + +def test_matched_terms_counts_distinct_stemmed_overlap() -> None: + from omind import retrieve + + # stemming folds variants; stopwords/filler don't count. ("retry" and + # "retries" deliberately do NOT fold — the conservative stemmer has no "y" + # rule — so the shared-term words here are ones the stemmer does fold.) + assert retrieve.matched_terms("consult the gate scoring", "gates scored on consults") == 3 + assert retrieve.matched_terms("retry", "unrelated note about a retry") == 1 + assert retrieve.matched_terms("", "anything") == 0 + + +def test_preflight_min_terms_env_override(monkeypatch) -> None: + from omind import retrieve + + assert retrieve.preflight_min_terms() == 2 # default + monkeypatch.setenv(retrieve.PREFLIGHT_MIN_TERMS_ENV, "3") + assert retrieve.preflight_min_terms() == 3 + monkeypatch.setenv(retrieve.PREFLIGHT_MIN_TERMS_ENV, "0") + assert retrieve.preflight_min_terms() == 0 + monkeypatch.setenv(retrieve.PREFLIGHT_MIN_TERMS_ENV, "junk") + assert retrieve.preflight_min_terms() == 2 # bad value -> default + monkeypatch.setenv(retrieve.PREFLIGHT_MIN_TERMS_ENV, "-4") + assert retrieve.preflight_min_terms() == 0 # clamped