diff --git a/CHANGELOG.md b/CHANGELOG.md index 13a82f6..5d3634a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.7.3] - 2026-07-01 + +### Fixed + +- **Repo work now requires the git operational OMI note plus a fresh-base check.** + The guard no longer treats an arbitrary OMI consult as enough before repo review, + edits, tests, commits, pushes, or releases. In a git repo, repo-sensitive actions + require reading `Operational Rules - Git Repos and Secrets` during the turn and + running a same-turn `git fetch --all --prune` or `git pull --ff-only` freshness + command first. +- **Global config/hook/bootstrap writes now require explicit current-turn user + authorization.** The guard blocks installed agent config/hook/bootstrap mutation + when the user asked a question rather than clearly authorizing the change. +- **Codex AGENTS bootstrap now spells out the repo freshness and global-config + authorization rules.** `omind setup --agent codex` updates the managed block so + fresh Codex sessions see the rule before acting. + ## [3.7.2] - 2026-07-01 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index 94777e4..be38624 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "omind" -version = "3.7.2" +version = "3.7.3" description = "Reproduce the OMI/Obsidian memory integration for AI agents, plus a local web app to view, edit, and add memory entries." readme = "README.md" requires-python = ">=3.10" diff --git a/src/omind/__init__.py b/src/omind/__init__.py index 410d89b..cca6af3 100644 --- a/src/omind/__init__.py +++ b/src/omind/__init__.py @@ -2,4 +2,4 @@ # Copyright 2026 Aaron K. Clark """omind — OMI/Obsidian memory tooling for AI agents.""" -__version__ = "3.7.2" +__version__ = "3.7.3" diff --git a/src/omind/adapters.py b/src/omind/adapters.py index bfb4353..d92a293 100644 --- a/src/omind/adapters.py +++ b/src/omind/adapters.py @@ -57,13 +57,23 @@ def normalize_action(event: dict[str, Any]) -> dict[str, Any]: or _first_str(tool_input, ("command",)) or _first_str(event, ("args", "input")) ) + file_path = _first_str(tool_input, ("file_path", "path")) or _first_str( + event, ("file_path", "path") + ) session = _first_str(event, ("session", "session_id")) is_consult = tool.startswith(_OMI_CONSULT_PREFIXES) or bool(event.get("is_omi_consult")) + consult_target = ( + _first_str(tool_input, ("name", "query", "q", "file_path", "path", "pattern")) + or _first_str(event, ("consult_target",)) + ) return { "tool": tool, "command": command, "session": session, "is_omi_consult": is_consult, + "file_path": file_path, + "consult_target": consult_target, + "consult_kind": "read" if "read" in tool.lower() else "search", } diff --git a/src/omind/guard.py b/src/omind/guard.py index 2db62b5..b2361a3 100644 --- a/src/omind/guard.py +++ b/src/omind/guard.py @@ -54,6 +54,19 @@ "(an OMI search or read), then retry. One consult clears the rest of the " "turn. This is NOT a prompt to open the credential/auth notes." ) +GIT_RULES_NOTE = "Operational Rules - Git Repos and Secrets" +GIT_RULES_MESSAGE = ( + "repo work requires reading OMI note `Operational Rules - Git Repos and Secrets` " + "this turn; a generic project-memory consult is not enough." +) +GIT_FRESHNESS_MESSAGE = ( + "repo work requires a same-turn freshness check before review/edit/test/commit/push: " + "run `git fetch --all --prune` or `git pull --ff-only`, then inspect branch status." +) +GLOBAL_MUTATION_MESSAGE = ( + "global config/hook/bootstrap mutation requires explicit user authorization in the " + "current turn; answer questions first instead of inferring permission." +) @dataclass(frozen=True) @@ -99,6 +112,7 @@ def begin_turn(session: str, task: str) -> None: (the bash turn-start hook clears the same counter file).""" _clear_reclose(session) _clear_pending(session) + _clear_git_freshness(session) with contextlib.suppress(OSError): path = _turn_path(session) path.parent.mkdir(parents=True, exist_ok=True) @@ -122,6 +136,10 @@ def _pending_path(session: str) -> Path: return paths.state_dir() / f"pending-{_safe_sid(session)}.txt" +def _git_fresh_path(session: str) -> Path: + return paths.state_dir() / f"git-fresh-{_safe_sid(session)}.json" + + def record_pending(session: str, text: str) -> None: """Stash the gate-blocked action's text as this turn's pending intent (best-effort, never raises). Empty/blank text is a no-op.""" @@ -147,6 +165,29 @@ def _clear_pending(session: str) -> None: _pending_path(session).unlink() +def _record_git_freshness(session: str, repo: Path, command: str) -> None: + if not session: + return + with contextlib.suppress(OSError): + path = _git_fresh_path(session) + path.parent.mkdir(parents=True, exist_ok=True) + payload = {"repo": str(repo), "command": command, "ts": int(time.time())} + path.write_text(json.dumps(payload), encoding="utf-8") + + +def _git_fresh_for_repo(session: str, repo: Path) -> bool: + try: + data = json.loads(_git_fresh_path(session).read_text(encoding="utf-8")) + except (OSError, ValueError): + return False + return isinstance(data, dict) and data.get("repo") == str(repo) + + +def _clear_git_freshness(session: str) -> None: + with contextlib.suppress(OSError): + _git_fresh_path(session).unlink() + + def _read_sentinel(session: str) -> dict[str, Any]: """The gate sentinel's JSON body ({} when empty/absent/garbage). The bash adapter creates the file empty (``touch``); Python enriches it with the @@ -371,7 +412,7 @@ def clear_all_gates() -> None: cannot know the live session id, so a single-session clear would miss it). Also reaps the legacy ``/tmp`` sentinels. Never raises.""" state = paths.state_dir() - for pattern in ("gate-*", "reclose-*", "pending-*", "offtopic-*"): + for pattern in ("gate-*", "reclose-*", "pending-*", "offtopic-*", "git-fresh-*"): try: stale = list(state.glob(pattern)) except OSError: @@ -387,6 +428,40 @@ def clear_all_gates() -> None: #: to clear the gate is to consult OMI, but where the OMI tools are deferred the #: consult needs the very schema this tool loads. _GATE_EXEMPT_TOOLS = frozenset({"ToolSearch"}) +_WRITE_TOOLS = frozenset( + { + "Edit", + "MultiEdit", + "Write", + "NotebookEdit", + "apply_patch", + "functions.apply_patch", + } +) +_READ_REVIEW_TOOLS = frozenset({"Read", "Grep", "Glob", "LS", "find", "rg"}) +_REPO_TEST_RE = re.compile( + r"(?:^|[;&|\n(]\s*)(?:uv|pytest|python|tox|nox|hatch|npm|pnpm|yarn|cargo|go|make)\b" +) +_GIT_FRESH_ONLY_RE = re.compile( + r"^\s*git\s+(?:fetch\b[^;&|\n]*|pull\b[^;&|\n]*(?:--ff-only|--rebase)[^;&|\n]*)\s*$" +) +_GIT_STATUS_ONLY_RE = re.compile(r"^\s*git\s+(?:status|rev-parse|branch|remote)\b[^;&|\n]*$") +_GLOBAL_CONFIG_RE = re.compile( + r"(?:^|[\s'\"=:/])(?:~?/)?(?:" + r"\.codex/(?:AGENTS\.md|hooks\.json|config\.toml)|" + r"\.claude/(?:settings\.json|hooks/[^ \t\n'\";]+)|" + r"\.hermes/(?:config\.yaml|hooks/[^ \t\n'\";]+|AGENTS\.md)|" + r"\.config/opencode/(?:opencode\.json|plugin/omi-guard\.js)|" + r"\.gemini/settings\.json|" + r"\.openclaw/(?:openclaw\.json|omind/MEMORY\.md)" + r")" +) +_GLOBAL_AUTH_RE = re.compile( + r"\b(?:please\s+)?(?:" + r"make|modify|edit|write|install|update|change|patch|apply|do it|go ahead|proceed" + r")\b", + re.IGNORECASE, +) def _opt_in_satisfied(opt_in: str, command: str) -> bool: @@ -408,10 +483,86 @@ def _opt_in_satisfied(opt_in: str, command: str) -> bool: return re.search(pattern, command) is not None +def _action_path(action: dict[str, Any]) -> str: + for key in ("file_path", "path"): + value = action.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return "" + + +def _repo_root_for_action(action: dict[str, Any]) -> Path | None: + candidates: list[Path] = [] + raw_path = _action_path(action) + if raw_path: + p = Path(raw_path).expanduser() + candidates.append(p if p.is_dir() else p.parent) + else: + candidates.append(Path.cwd()) + for candidate in candidates: + try: + cur = candidate.resolve() + except OSError: + cur = candidate.absolute() + for parent in (cur, *cur.parents): + if (parent / ".git").exists(): + return parent + return None + + +def _has_consulted_git_rules(session: str) -> bool: + needle = GIT_RULES_NOTE.lower() + for consult in consults(session): + target = str(consult.get("target") or "").lower() + if needle in target: + return True + return False + + +def _is_repo_sensitive_action(action: dict[str, Any]) -> bool: + tool = str(action.get("tool") or "") + command = str(action.get("command") or "") + path = _action_path(action) + if tool in _WRITE_TOOLS or tool in _READ_REVIEW_TOOLS: + return True + if tool == "Bash": + if _GIT_FRESH_ONLY_RE.match(command) or _GIT_STATUS_ONLY_RE.match(command): + return False + if re.search( + r"(?:^|[;&|\n(]\s*)git\s+(?:add|commit|push|merge|rebase|checkout|switch)\b", + command, + ): + return True + if re.search(r"(?:^|[;&|\n(]\s*)gh\s+(?:pr|release|repo)\b", command): + return True + if _REPO_TEST_RE.search(command): + return True + if re.search(r"(?:^|[;&|\n(]\s*)(?:sed|perl|python|python3|node|ruby)\b", command) and ( + " -i" in command or "write_text" in command or "Path(" in command + ): + return True + return bool(path) + + +def _is_global_config_mutation(action: dict[str, Any]) -> bool: + tool = str(action.get("tool") or "") + if tool not in _WRITE_TOOLS and tool != "Bash": + return False + haystack = " ".join( + part for part in (str(action.get("command") or ""), _action_path(action)) if part + ).replace("\\", "/") + return bool(_GLOBAL_CONFIG_RE.search(haystack)) + + +def _turn_has_explicit_global_auth(session: str) -> bool: + return bool(_GLOBAL_AUTH_RE.search(turn_task(session))) + + def decide(action: dict[str, Any]) -> Verdict: """The harness-agnostic policy. See the module docstring for the schema.""" session = str(action.get("session") or "") command = str(action.get("command") or "") + repo = _repo_root_for_action(action) # 1) Consulting OMI sets the per-turn sentinel and is always allowed. When # the adapter knows what was consulted, record it (with target) so the @@ -444,6 +595,10 @@ def decide(action: dict[str, Any]) -> Verdict: rule_id=rule.id, ) + if repo is not None and _GIT_FRESH_ONLY_RE.match(command): + _record_git_freshness(session, repo, command) + return Verdict(allow=True) + # 2.5) Tool-schema loading (e.g. ToolSearch) is never gated. It already # passed the hard blocks above; skip the gate WITHOUT satisfying it (loading # a schema is not a consult), so a deferred OMI tool can be loaded and then @@ -459,6 +614,29 @@ 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(session): + return Verdict( + allow=False, + reason=f"omi-guard (hard): {GLOBAL_MUTATION_MESSAGE}", + rule_id="global-config-explicit-auth", + ) + + if repo is not None and _is_repo_sensitive_action(action): + if not _has_consulted_git_rules(session): + record_pending(session, command or _action_path(action)) + return Verdict( + allow=False, + reason=f"omi-guard (hard): {GIT_RULES_MESSAGE}", + rule_id="repo-work-read-git-rules", + ) + if not _git_fresh_for_repo(session, repo): + record_pending(session, command or _action_path(action)) + return Verdict( + allow=False, + reason=f"omi-guard (hard): {GIT_FRESHNESS_MESSAGE}", + rule_id="repo-work-fresh-base", + ) + # 3) The gate — block until OMI was consulted this turn. if consulted_this_turn(session): return Verdict(allow=True) diff --git a/src/omind/omi-guard-hermes.sh b/src/omind/omi-guard-hermes.sh index 13c1593..a416d7b 100644 --- a/src/omind/omi-guard-hermes.sh +++ b/src/omind/omi-guard-hermes.sh @@ -29,7 +29,13 @@ SENT="$STATE/gate-$sid" # Consulting OMI clears the per-turn gate (always allowed — the clear-path). # `touch` (not truncate) so the PostToolUse verifier's JSON survives the turn. case "$tool" in - mcp__omi__*) mkdir -p "$STATE" 2>/dev/null; touch "$SENT" 2>/dev/null; exit 0 ;; + mcp__omi__*) + target="$(printf '%s' "$input" | jq -r '.tool_input.name // .tool_input.query // .tool_input.q // .tool_input.file_path // .tool_input.path // empty' 2>/dev/null)" + jq -nc --arg t "$tool" --arg s "$sid" --arg target "$target" \ + '{tool:$t, command:"", session:$s, is_omi_consult:true, consult_target:$target}' 2>/dev/null \ + | "$OMIND" guard adapter --harness hermes --omi-dir "$OMI_DIR" >/dev/null 2>&1 + exit 0 + ;; # Tool-schema loading is never gated: deferred OMI MCP tools become callable # only via ToolSearch, so gating it deadlocks the turn (no consult possible). # Allow it through WITHOUT clearing the gate — loading a schema is not a consult. @@ -48,7 +54,12 @@ if [ "$tool" = "Read" ] || [ "$tool" = "read_file" ]; then # with paths.NON_CONSULT_FILENAMES.) case "${fp##*/}" in index.md|MEMORY.md|"Memory Template.md") exit 0 ;; - *) mkdir -p "$STATE" 2>/dev/null; touch "$SENT" 2>/dev/null; exit 0 ;; + *) + jq -nc --arg s "$sid" --arg target "$fp" \ + '{tool:"Read", command:"", session:$s, is_omi_consult:true, consult_target:$target, consult_kind:"read", file_path:$target}' 2>/dev/null \ + | "$OMIND" guard adapter --harness hermes --omi-dir "$OMI_DIR" >/dev/null 2>&1 + exit 0 + ;; esac ;; esac diff --git a/src/omind/omi-guard.sh b/src/omind/omi-guard.sh index 3f8f8e0..d4f5a2e 100644 --- a/src/omind/omi-guard.sh +++ b/src/omind/omi-guard.sh @@ -61,7 +61,13 @@ SENT="$STATE/gate-$sid" # turn's consults + relevance verdicts as JSON in this same file, and a second # consult in the turn must not wipe the first. case "$tool" in - mcp__omi__*) mkdir -p "$STATE" 2>/dev/null; touch "$SENT" 2>/dev/null; exit 0 ;; + mcp__omi__*) + target="$(printf '%s' "$input" | jq -r '.tool_input.name // .tool_input.query // .tool_input.q // .tool_input.file_path // .tool_input.path // empty' 2>/dev/null)" + jq -nc --arg t "$tool" --arg s "$sid" --arg target "$target" \ + '{tool:$t, command:"", session:$s, is_omi_consult:true, consult_target:$target}' 2>/dev/null \ + | "$OMIND" guard check >/dev/null 2>&1 + exit 0 + ;; # Tool-schema loading is never gated: deferred OMI MCP tools become callable # only via ToolSearch, so gating it deadlocks the turn (no consult possible). # Allow it through WITHOUT clearing the gate — loading a schema is not a consult. @@ -80,7 +86,12 @@ if [ "$tool" = "Read" ]; then # with paths.NON_CONSULT_FILENAMES.) case "${fp##*/}" in index.md|MEMORY.md|"Memory Template.md") exit 0 ;; - *) mkdir -p "$STATE" 2>/dev/null; touch "$SENT" 2>/dev/null; exit 0 ;; + *) + jq -nc --arg s "$sid" --arg target "$fp" \ + '{tool:"Read", command:"", session:$s, is_omi_consult:true, consult_target:$target, consult_kind:"read", file_path:$target}' 2>/dev/null \ + | "$OMIND" guard check >/dev/null 2>&1 + exit 0 + ;; esac ;; esac @@ -121,11 +132,17 @@ if [ -f "$PAUSE" ]; then if [ -n "$exp" ] && [ -n "$now" ] && [ "$exp" -gt "$now" ] 2>/dev/null; then exit 0; fi fi -# All other tools: the per-turn gate. The common post-consult case is a pure -# bash existence check (no subprocess). Only the first BLOCKED action of a turn -# pays one subprocess to name the notes relevant to this turn's task (Phase 3.2), -# falling back to the static message if omind can't be reached. -[ -e "$SENT" ] && exit 0 +# All other tools: delegate to the core so repo/global-config preconditions can +# inspect file paths. This is slower than the old sentinel-only fast path, but the +# policy now needs more context than "has OMI been consulted". +fp="$(printf '%s' "$input" | jq -r '.tool_input.file_path // .tool_input.path // .file_path // .path // empty' 2>/dev/null)" +jq -nc --arg t "$tool" --arg s "$sid" --arg fp "$fp" \ + '{tool:$t, command:"", session:$s, is_omi_consult:false, file_path:$fp}' 2>/dev/null \ + | "$OMIND" guard check +rc=$? +case "$rc" in + 0 | 2) exit "$rc" ;; +esac if msg="$(printf '%s' "$input" | "$OMIND" guard suggest --omi-dir "$OMI_DIR" 2>/dev/null)" \ && [ -n "$msg" ]; then printf '%s\n' "$msg" >&2 diff --git a/src/omind/seeds.py b/src/omind/seeds.py index 9d175c0..53fc303 100644 --- a/src/omind/seeds.py +++ b/src/omind/seeds.py @@ -278,5 +278,17 @@ If OMI and the user's explicit current instruction conflict, the current instruction wins for that turn. If OMI is unavailable, proceed from this bootstrap and say that OMI could not be read. + +Repo and global-config work has extra hard requirements: + +- Before reviewing, editing, testing, committing, pushing, or releasing any git + repo, read `Operational Rules - Git Repos and Secrets` from OMI in addition to + any project note. +- Before touching repo code, run `git status --short --branch` and a freshness + command (`git fetch --all --prune` or `git pull --ff-only`), then resolve the + current branch/base state. +- Do not infer permission to edit installed global agent config, hooks, bootstrap + files, or JUMPSTART-style instructions from a question. Answer the question + first; change those files only after explicit current-turn authorization. """ diff --git a/tests/test_adapters.py b/tests/test_adapters.py index 9f9cee6..dc38eb6 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -16,7 +16,15 @@ def test_normalize_claude_shape() -> None: action = adapters.normalize_action( {"tool_name": "Bash", "tool_input": {"command": "ls"}, "session_id": "s"} ) - assert action == {"tool": "Bash", "command": "ls", "session": "s", "is_omi_consult": False} + assert action == { + "tool": "Bash", + "command": "ls", + "session": "s", + "is_omi_consult": False, + "file_path": "", + "consult_target": "", + "consult_kind": "search", + } def test_normalize_other_harness_shapes() -> None: @@ -24,8 +32,11 @@ def test_normalize_other_harness_shapes() -> None: action = adapters.normalize_action({"tool": "shell", "command": "gh pr create", "session": "h"}) assert action["command"] == "gh pr create" and action["session"] == "h" # An mcp__omi__ tool is recognized as a consult regardless of harness. - consult = adapters.normalize_action({"name": "mcp__omi__search-vault", "session": "h"}) + consult = adapters.normalize_action( + {"name": "mcp__omi__read-note", "tool_input": {"name": "Operational Rules"}, "session": "h"} + ) assert consult["is_omi_consult"] is True + assert consult["consult_target"] == "Operational Rules" # `args` is accepted as the command when no command/tool_input is present. assert adapters.normalize_action({"args": "rm -rf /"})["command"] == "rm -rf /" diff --git a/tests/test_agents.py b/tests/test_agents.py index ec689b7..db6111c 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -583,6 +583,9 @@ def test_codex_setup_installs_global_agents_bootstrap(tmp_path: Path) -> None: assert "This section is managed by `omind setup --agent codex`" in text assert str(config.omi_dir) in text assert "Voice and Persona - Dix and Shelly" in text + assert "Operational Rules - Git Repos and Secrets" in text + assert "git fetch --all --prune" in text + assert "Do not infer permission to edit installed global agent config" in text def test_codex_global_agents_bootstrap_preserves_user_text_and_is_idempotent( diff --git a/tests/test_guard.py b/tests/test_guard.py index aa80226..785c262 100644 --- a/tests/test_guard.py +++ b/tests/test_guard.py @@ -27,6 +27,13 @@ ) +def _satisfy_repo_preconditions(session: str) -> None: + guard.record_consult(session, kind="read", target=guard.GIT_RULES_NOTE, relevant=True) + repo = guard._repo_root_for_action({"tool": "Bash", "command": "git status"}) + assert repo is not None + guard._record_git_freshness(session, repo, "git fetch --all --prune") + + def test_omi_consult_is_allowed_and_sets_the_per_turn_sentinel() -> None: guard.clear_gate("s1") assert guard.decide({"is_omi_consult": True, "session": "s1"}).allow @@ -63,7 +70,7 @@ def test_full_destructive_set_is_blocked() -> None: def test_codeberg_push_is_allowed_after_consult() -> None: - guard.mark_consulted("s5") + _satisfy_repo_preconditions("s5") cmd = "git push git@codeberg.org:CryptoJones/omind.git main" assert guard.decide({"command": cmd, "session": "s5"}).allow guard.clear_gate("s5") @@ -142,6 +149,56 @@ def test_run_guard_check_and_reset_exit_codes() -> None: assert not guard.consulted_this_turn("s6") +def test_repo_work_requires_git_rules_note_and_freshness_check() -> None: + guard.clear_gate("repo") + blocked = guard.decide({"tool": "Bash", "command": "pytest", "session": "repo"}) + assert not blocked.allow + assert blocked.rule_id == "repo-work-read-git-rules" + + guard.record_consult("repo", kind="read", target=guard.GIT_RULES_NOTE, relevant=True) + blocked = guard.decide({"tool": "Bash", "command": "pytest", "session": "repo"}) + assert not blocked.allow + assert blocked.rule_id == "repo-work-fresh-base" + + compound = guard.decide( + {"tool": "Bash", "command": "git fetch --all --prune && pytest", "session": "repo"} + ) + assert not compound.allow + assert compound.rule_id == "repo-work-fresh-base" + + fresh = guard.decide( + {"tool": "Bash", "command": "git fetch --all --prune", "session": "repo"} + ) + assert fresh.allow + assert guard.decide({"tool": "Bash", "command": "pytest", "session": "repo"}).allow + guard.clear_gate("repo") + + +def test_global_config_mutation_requires_explicit_turn_authorization() -> None: + guard.begin_turn("global", "Can you fix both?") + blocked = guard.decide( + { + "tool": "Write", + "file_path": str(Path.home() / ".codex" / "AGENTS.md"), + "session": "global", + } + ) + assert not blocked.allow + assert blocked.rule_id == "global-config-explicit-auth" + + guard.begin_turn("global", "Please update the global Codex AGENTS bootstrap.") + allowed = guard.decide( + { + "tool": "Write", + "file_path": str(Path.home() / ".codex" / "AGENTS.md"), + "session": "global", + } + ) + assert not allowed.allow + assert allowed.rule_id == "omi-gate" + guard.clear_gate("global") + + def test_clear_gate_reaps_legacy_tmp_sentinels( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -391,7 +448,7 @@ def test_pause_engagement_is_logged_for_audit() -> None: def test_opt_in_must_be_a_real_leading_assignment_not_a_substring() -> None: """#2: the opt-in token only bypasses a hard rule when it is a genuine leading env assignment — forging it in a comment or a string must NOT skip the deny.""" - guard.mark_consulted("optf") + _satisfy_repo_preconditions("optf") # forged in a trailing comment -> not a real assignment -> still denied assert not guard.decide({"command": "sudo reboot # OMI_SUDO_OK=1", "session": "optf"}).allow # forged inside a string arg -> still denied @@ -482,6 +539,45 @@ def _fake_omind(tmp_path: Path, exit_code: int) -> Path: return fake +def _fake_consult_omind(tmp_path: Path) -> Path: + fake = tmp_path / "fake-consult-omind" + fake.write_text( + f"""#!{sys.executable} +import json +import os +import pathlib +import sys + +data = json.loads(sys.stdin.read() or "{{}}") +if sys.argv[1:3] == ["guard", "check"] and data.get("is_omi_consult"): + sid = "".join(ch for ch in str(data.get("session") or "nosid") if ch.isalnum() or ch in "._-") + if os.environ.get("XDG_STATE_HOME"): + base = pathlib.Path(os.environ["XDG_STATE_HOME"]) + else: + base = pathlib.Path.home() / ".local" / "state" + state = base / "omind" + state.mkdir(parents=True, exist_ok=True) + payload = {{ + "consults": [ + {{ + "kind": data.get("consult_kind", "consult"), + "target": data.get("consult_target", ""), + "relevant": None, + }} + ] + }} + (state / f"gate-{{sid or 'nosid'}}").write_text( + json.dumps(payload), + encoding="utf-8", + ) +sys.exit(0) +""", + encoding="utf-8", + ) + fake.chmod(0o755) + return fake + + @pytest.mark.skipif(not _NOJQ_TESTABLE, reason="needs posix bash + coreutils") def test_hook_routes_through_adapter_when_jq_missing(tmp_path: Path) -> None: """#107: without jq the hook must NOT wedge — it routes the raw event through @@ -536,9 +632,8 @@ def _read_event(omi: Path, name: str, sid: str) -> dict[str, object]: def test_hook_index_read_does_not_clear_the_gate_but_real_note_does(tmp_path: Path) -> None: """The index.md gate-dodge: a Read of the vault TOC / MEMORY.md / template under the OMI folder is ALLOWED but must NOT clear the per-turn gate, while a - Read of a real content note still does. The core binary is never invoked on a - Read, so a nonexistent path is fine here.""" - hook = _render_hook(tmp_path, "/nonexistent/omind") + Read of a real content note still does.""" + hook = _render_hook(tmp_path, str(_fake_consult_omind(tmp_path))) omi = tmp_path / "OMI" # matches __OMI_DIR__ substituted by _render_hook for scaffold in ("index.md", "MEMORY.md", "Memory Template.md"): guard.clear_gate("hidx") diff --git a/tests/test_verify.py b/tests/test_verify.py index 64c3e8a..60924ed 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -463,11 +463,12 @@ def test_consult_off_topic_to_both_task_and_activity_still_irrelevant( def test_gate_block_records_pending_intent_and_turn_start_clears_it() -> None: guard.begin_turn("pi", "some task") - # Not consulted yet: the gate blocks a (benign) Bash action and records its command. + # Not consulted yet: repo-sensitive work blocks before the generic gate and + # still records pending intent for the relevance verifier. verdict = guard.decide( {"tool": "Bash", "command": "cargo test -p scylla-merge", "session": "pi"} ) - assert not verdict.allow and verdict.rule_id == "omi-gate" + assert not verdict.allow and verdict.rule_id == "repo-work-read-git-rules" assert guard.pending_intent("pi") == "cargo test -p scylla-merge" guard.begin_turn("pi", "next turn") # turn start resets the per-turn pending intent assert guard.pending_intent("pi") == "" diff --git a/uv.lock b/uv.lock index 2b81a9f..2d746de 100644 --- a/uv.lock +++ b/uv.lock @@ -2364,7 +2364,7 @@ wheels = [ [[package]] name = "omind" -version = "3.7.2" +version = "3.7.3" source = { editable = "." } dependencies = [ { name = "fastapi" },