From 0095cef48a41443e63d06ebc516af6221f20715f Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark" Date: Sun, 4 Oct 2026 07:52:52 -0500 Subject: [PATCH 1/2] feat(grok): support the Grok Build client omind setup --agent grok registers the omi MCP server in ~/.grok/config.toml, lifecycle hooks in ~/.grok/hooks/omind.json, the packaged skill, and a home rule at ~/.grok/rules/omind.md. The guard accepts Grok's camelCase hook events, including when Grok runs the Claude-compatible hook, maps built-in tool names onto the names it already classifies, and counts omi__ tools as consults. A deny is stdout JSON with exit 0, so the guard reason is what Grok shows. The embedding model load no longer prints a Hugging Face progress bar onto that channel. omind quickstart --agent grok prints the same wiring. Closes #470 Co-authored-by: Grok 4.7 --- BACKLOG.md | 8 + CHANGELOG.md | 21 +++ README.md | 17 +- pyproject.toml | 2 +- src/omind/__init__.py | 2 +- src/omind/adapters.py | 16 +- src/omind/agents.py | 374 +++++++++++++++++++++++++++++++++++++- src/omind/cli.py | 2 +- src/omind/embed.py | 5 + src/omind/harness.py | 169 ++++++++++++++++- src/omind/hooks.py | 2 +- src/omind/quickstart.py | 98 ++++++++++ src/omind/seeds.py | 42 +++++ tests/test_adapters.py | 2 +- tests/test_grok_client.py | 246 +++++++++++++++++++++++++ tests/test_harness.py | 5 + tests/test_quickstart.py | 44 +++++ uv.lock | 2 +- 18 files changed, 1045 insertions(+), 12 deletions(-) create mode 100644 tests/test_grok_client.py diff --git a/BACKLOG.md b/BACKLOG.md index 62baeb7..79265a6 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -79,6 +79,14 @@ _Closed as not planned or rejected. Kept for the record; nothing here is pending ## Done +### Shipped — 2026-10-04 (v10.3.0) + +- [x] **Grok Build client: setup, doctor, guard payloads, and quickstart** ([#470](https://github.com/CryptoJones/omind/issues/470)) — **v10.3.0** — _feature_ — + `omind setup --agent grok` registers the MCP server, hooks, skill, and rules file. + The guard reads Grok's camelCase events (including when Grok runs the Claude-compatible + hook), counts `omi__…` as a consult, and denies with stdout JSON so the reason is visible. + `omind quickstart --agent grok` prints the same wiring. + ### Shipped — 2026-10-02 (v10.2.28) - [x] **guard: `decide()` raises OSError (ENAMETOOLONG) on long cd chains; add an overall fail-closed hook deadline** ([#460](https://github.com/CryptoJones/omind/issues/460)) — **v10.2.28** — _bug_ — diff --git a/CHANGELOG.md b/CHANGELOG.md index f3d0b8a..9e647a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [10.3.0] - 2026-10-04 + +### Added +- **Grok Build (`grok`) support + ([#470](https://github.com/CryptoJones/omind/issues/470)).** `omind setup --agent grok` registers the `omi` + MCP server in `~/.grok/config.toml`, installs lifecycle hooks in + `~/.grok/hooks/omind.json` (`PreToolUse`, `UserPromptSubmit`, `PostToolUse`, + `Stop`, `SessionStart`), installs the packaged skill under + `~/.grok/skills/omind/`, and writes a managed bootstrap rule at + `~/.grok/rules/omind.md`. Grok does not inject `SessionStart` stdout, so the + rules file is what primes a session. `omind doctor --agent grok` checks the + wiring. `omind quickstart --agent grok` prints the same MCP table, hook + file, rules block, and skill path. +- **Grok hook payloads.** `omind guard adapter` accepts Grok's camelCase events + (`toolName`, `toolInput`, `sessionId`, `hookEventName`), including when Grok + is running the Claude-compatible hook. Built-in tool names map onto the names + the guard already classifies, and `omi__…` counts as a memory consult. A deny + is `{"decision":"deny","reason":…}` on stdout so the guard text is what Grok + shows. The embedding model load no longer prints a Hugging Face progress bar + onto that channel. + ## [10.2.28] - 2026-10-02 ### Fixed diff --git a/README.md b/README.md index ec80d9e..1b76154 100644 --- a/README.md +++ b/README.md @@ -662,6 +662,21 @@ under Claude Code blocks under Laguna too. Re-runs replace only the entries omind owns (named `omind-omi-*`), so hooks you wrote and pool's own `stop_hook_max_continuations` are preserved. +**Grok Build** (the `grok` CLI) gets memory, the guard, the skill, and a home +rule from one `omind setup --agent grok`. The `omi` MCP server is registered +under `[mcp_servers.omi]` in `~/.grok/config.toml`. Hooks go in +`~/.grok/hooks/omind.json` (other files in that directory are left alone): +`PreToolUse` hard-blocks via `omind guard adapter --harness grok`, +`UserPromptSubmit` runs the per-turn preflight, and `PostToolUse` / `Stop` / +`SessionStart` do accounting, the loop guard, and session bookkeeping. Grok +does not inject `SessionStart` stdout, so priming is a managed +`~/.grok/rules/omind.md` plus the packaged skill in `~/.grok/skills/omind/`. +Grok also runs Claude Code's hooks and sends them a camelCase payload +(`toolName`, `toolInput`, `sessionId`); the guard accepts that shape and +answers a deny as `{"decision":"deny","reason":…}`. `$GROK_HOME` overrides +`~/.grok`. `omind quickstart --agent grok` prints the same wiring to apply +by hand. + **goose** (Block's on-machine agent) gets OMI **memory + priming** from one `omind setup --agent goose` — it has no blocking pre-tool or session-start shell hooks, so the guard is not wired here. The `omi` MCP server is registered as a @@ -696,7 +711,7 @@ as Claude Desktop but `Code/User`; a `servers` block with `type: stdio`); and Am Q's `~/.aws/amazonq/mcp.json` (`mcpServers`). Restart the tool afterward to load the server. -`omind doctor --agent hermes|openclaw|opencode|codex|gemini|poolside|goose|claude-desktop|kiro|vscode|q` +`omind doctor --agent hermes|openclaw|opencode|codex|gemini|poolside|goose|grok|claude-desktop|kiro|vscode|q` diagnoses that agent's wiring, and `omind quickstart --agent ` prints the manual steps (YAML/JSON snippets personalized to your paths) if you'd rather merge them in yourself. diff --git a/pyproject.toml b/pyproject.toml index abad870..d7d34ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "omind" -version = "10.2.28" +version = "10.3.0" 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 8983b85..825a16a 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__ = "10.2.28" +__version__ = "10.3.0" diff --git a/src/omind/adapters.py b/src/omind/adapters.py index 5f60c5f..b8bce8a 100644 --- a/src/omind/adapters.py +++ b/src/omind/adapters.py @@ -160,6 +160,11 @@ def run_adapter( return harness_mod.render_decision( blocked, spec.block_format, sys.stdout, sys.stderr, event="" ) + # Grok runs Claude's hooks and sends a camelCase payload. Deny with Grok's + # JSON decision so the reason is the guard text, not the first stderr line. + fmt = spec.block_format + if harness == "grok" or harness_mod.payload_is_grok(event): + fmt = harness_mod.FMT_GROK # Poolside's event shape differs from Claude's in three places (tool naming, # ``cmd``, ``tool_output``); translate ONCE here so the guard, verifier, and # accounting all see the Claude-shaped event they were written against. @@ -184,7 +189,7 @@ def run_adapter( verdict = guard.check_action(action, omi_dir=omi_dir) stage = "adapter render" return harness_mod.render_decision( - verdict, spec.block_format, sys.stdout, sys.stderr, event=hook_event + verdict, fmt, sys.stdout, sys.stderr, event=hook_event ) except Exception as exc: # ``check_action`` logs its own decision (and never raises), so a verdict @@ -193,10 +198,10 @@ def run_adapter( fallback = _fail_open_verdict(event, action, exc, verdict, stage=stage) try: return harness_mod.render_decision( - fallback, spec.block_format, sys.stdout, sys.stderr, event=hook_event + fallback, fmt, sys.stdout, sys.stderr, event=hook_event ) except Exception: - return _render_last_resort(spec.block_format, fallback.allow, hook_event) + return _render_last_resort(fmt, fallback.allow, hook_event) #: Fixed literal outputs for when :func:`omind.harness.render_decision` raises @@ -273,6 +278,11 @@ def run_adapter( 0, ), "agy": (json.dumps({"decision": "deny", "reason": _LAST_RESORT_REASON}) + "\n", "", 0), + "grok": ( + json.dumps({"decision": "deny", "reason": f"OMI guard: {_LAST_RESORT_REASON}"}) + "\n", + "", + 0, + ), } _LAST_RESORT_ALLOW: dict[str, str] = { "json_signal": json.dumps({"allow": True, "reason": "", "rule_id": ""}) + "\n", diff --git a/src/omind/agents.py b/src/omind/agents.py index cdf3ec6..e4151fa 100644 --- a/src/omind/agents.py +++ b/src/omind/agents.py @@ -256,6 +256,46 @@ def codex_skill_dir() -> Path: ) +def grok_config_dir() -> Path: + """Grok Build's config directory: ``$GROK_HOME`` or ``~/.grok``.""" + base = os.environ.get("GROK_HOME") + return Path(base) if base else Path.home() / ".grok" + + +def grok_config_path() -> Path: + """Grok's user config. MCP servers live in ``[mcp_servers.]``.""" + return grok_config_dir() / "config.toml" + + +def grok_hooks_path() -> Path: + """The hook file omind owns. Sibling JSON files in the same directory stay.""" + return grok_config_dir() / "hooks" / "omind.json" + + +def grok_skill_dir() -> Path: + """User-scope Grok skill installed by omind setup.""" + return grok_config_dir() / "skills" / "omind" + + +def grok_rules_path() -> Path: + """Home rule Grok injects into every session. SessionStart stdout is ignored, + so this file is the priming channel.""" + return grok_config_dir() / "rules" / "omind.md" + + +#: Substring identifying omind's own Grok guard hook, so doctor can see it. +GROK_GUARD_MARKER = "guard adapter --harness grok" +GROK_BOOTSTRAP_START = "" +GROK_BOOTSTRAP_END = "" +GROK_HOOK_EVENTS = ( + "PreToolUse", + "UserPromptSubmit", + "PostToolUse", + "Stop", + "SessionStart", +) + + def _codex_omind_hook(event: str, hook: dict[str, Any]) -> bool: command = str(hook.get("command", "")) if event in {"PreToolUse", "PermissionRequest"}: @@ -3479,6 +3519,336 @@ def diagnose_agy(config: SetupConfig) -> list[CheckResult]: return results +# -- Grok Build ----------------------------------------------------------------- + + +class GrokProvisioner(AgentProvisioner): + """Wire Grok Build: the ``omi`` MCP server, guard hooks, skill, and a home rule. + + Grok reads MCP servers from ``[mcp_servers.]`` in ``~/.grok/config.toml`` + and hooks from every JSON file under ``~/.grok/hooks/``. omind owns + ``hooks/omind.json`` and leaves every other file alone. Grok does not inject + ``SessionStart`` or allowing ``UserPromptSubmit`` stdout, so priming is a + managed ``~/.grok/rules/omind.md`` (loaded into the system prompt) plus the + packaged skill. The hooks still run: PreToolUse hard-blocks, UserPromptSubmit + resets the per-turn gate, and PostToolUse can inject ``additionalContext``. + """ + + AGENT_LABEL = "Grok Build" + INSTALL_HINT = "Install the Grok CLI (`grok`) and launch it once, then re-run." + DONE_MESSAGE = ( + "Done. Start a new Grok session to load the OMI memory tools, hooks, skill, " + "and rules." + ) + + def agent_root(self) -> Path: + return grok_config_dir() + + def skill_dir(self) -> Path: + return grok_skill_dir() + + def integrate(self) -> None: + self.register_mcp() + self.install_hooks() + self.install_packaged_skill(self.skill_dir()) + self.install_bootstrap() + + def _read_toml_config(self) -> tomlkit.TOMLDocument: + path = grok_config_path() + if not path.is_file(): + return tomlkit.document() + try: + return tomlkit.parse(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, tomlkit.exceptions.ParseError) as exc: + raise ProvisionError( + f"{path} is not valid TOML ({exc}). Refusing to overwrite it." + ) from exc + + def desired_mcp_entry(self) -> dict[str, Any]: + argv = canonical_omind_argv() + return { + "command": argv[0], + "args": [ + *argv[1:], + "node", + "--vault", + str(self.config.vault), + "--folder", + self.config.folder, + ], + } + + def registered_mcp_entry(self) -> dict[str, Any] | None: + try: + doc = self._read_toml_config() + except ProvisionError: + return None + servers = doc.get("mcp_servers") + entry = servers.get(self.config.server_name) if isinstance(servers, dict) else None + if not isinstance(entry, dict): + return None + return {"command": entry.get("command"), "args": list(entry.get("args") or [])} + + def register_mcp(self) -> None: + path = grok_config_path() + with filelock.exclusive(path.with_suffix(path.suffix + ".lock")): + doc = self._read_toml_config() + desired = self.desired_mcp_entry() + if self.registered_mcp_entry() == desired and not self.config.force: + self.log( + f" MCP server '{self.config.server_name}' already points at " + f"{self.config.omi_dir}" + ) + return + servers = doc.get("mcp_servers") + if not isinstance(servers, dict): + servers = tomlkit.table() + doc["mcp_servers"] = servers + elif self.config.server_name != LEGACY_SERVER_NAME and LEGACY_SERVER_NAME in servers: + del servers[LEGACY_SERVER_NAME] + entry = tomlkit.table() + entry["command"] = desired["command"] + entry["args"] = desired["args"] + servers[self.config.server_name] = entry + self._record( + f"register MCP server '{self.config.server_name}' in {path} -> " + f"{self.config.omi_dir}" + ) + if not self.config.dry_run: + path.parent.mkdir(parents=True, exist_ok=True) + paths.atomic_write_text(path, tomlkit.dumps(doc)) + + def _hook_command(self, event: str) -> str: + omind = self._omind_cmd() + omi = self._arg(self.config.omi_dir) + if event == "PreToolUse": + body = f"{omind} guard adapter --harness grok --omi-dir {omi}" + elif event == "UserPromptSubmit": + body = f"{omind} guard preflight --harness grok --omi-dir {omi}" + else: + body = f"{self._omind_hook_command(event)} --harness grok" + return self._hook_line(body) + + def desired_hooks_document(self) -> dict[str, Any]: + timeouts = { + "PreToolUse": 30, + "UserPromptSubmit": 30, + "PostToolUse": 30, + "Stop": 20, + "SessionStart": 20, + } + return { + "hooks": { + event: [ + { + "hooks": [ + { + "type": "command", + "command": self._hook_command(event), + "timeout": timeouts[event], + } + ] + } + ] + for event in GROK_HOOK_EVENTS + } + } + + def _read_hooks(self) -> dict[str, Any]: + return self._read_settings(grok_hooks_path()) + + def _hooks_wired(self) -> bool: + try: + data = self._read_hooks() + except ProvisionError: + return False + return data == self.desired_hooks_document() + + def install_hooks(self) -> None: + path = grok_hooks_path() + desired = self.desired_hooks_document() + try: + current = self._read_hooks() + except ProvisionError: + current = None + if current == desired and not self.config.force: + self.log(f" OMI hooks already installed in {path}") + return + self._record(f"install OMI hooks in {path}") + if self.config.dry_run: + return + path.parent.mkdir(parents=True, exist_ok=True) + with filelock.exclusive(path.with_suffix(path.suffix + ".lock")): + paths.atomic_write_text(path, json.dumps(desired, indent=2) + "\n") + + def bootstrap_content(self) -> str: + return seeds.GROK_RULES_TEMPLATE.format( + vault=self.config.vault, + folder=self.config.folder, + omi_dir=self.config.omi_dir, + ).rstrip() + "\n" + + def bootstrap_installed(self) -> bool: + path = grok_rules_path() + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return False + return GROK_BOOTSTRAP_START in text and GROK_BOOTSTRAP_END in text + + def install_bootstrap(self) -> None: + path = grok_rules_path() + desired = self.bootstrap_content() + try: + current = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + current = "" + start = current.find(GROK_BOOTSTRAP_START) + end = current.find(GROK_BOOTSTRAP_END) + if start >= 0 and end >= start: + prefix = current[:start].rstrip() + suffix = current[end + len(GROK_BOOTSTRAP_END) :].lstrip() + if prefix and suffix: + updated = f"{prefix}\n\n{desired}\n{suffix}" + elif prefix: + updated = f"{prefix}\n\n{desired}" + elif suffix: + updated = f"{desired}\n{suffix}" + else: + updated = desired + elif current.strip(): + updated = current.rstrip() + "\n\n" + desired + else: + updated = desired + if updated == current and not self.config.force: + self.log(f" OMI bootstrap pointer already in {path}") + return + self._record(f"install OMI bootstrap pointer in {path}") + if not self.config.dry_run: + path.parent.mkdir(parents=True, exist_ok=True) + paths.atomic_write_text(path, updated) + + def verify(self) -> None: + if self.config.dry_run: + return + if self.registered_mcp_entry() == self.desired_mcp_entry(): + self.log( + f" verified: MCP server '{self.config.server_name}' wired into " + f"{grok_config_path()}" + ) + else: + self.log( + " NOTE: could not confirm the MCP server in Grok's config.toml; " + "re-run with --force." + ) + if self._hooks_wired(): + self.log(f" verified: OMI hooks installed in {grok_hooks_path()}") + else: + self.log(" NOTE: could not confirm the OMI hooks; re-run with --force.") + skill = self.skill_dir() / paths.AGENT_SKILL_FILENAME + if skill.is_file(): + self.log(f" verified: omind skill installed in {skill}") + else: + self.log(" NOTE: could not confirm the omind skill; re-run with --force.") + if self.bootstrap_installed(): + self.log(f" verified: OMI bootstrap pointer installed in {grok_rules_path()}") + else: + self.log(" NOTE: could not confirm the OMI rules pointer; re-run with --force.") + + +def diagnose_grok(config: SetupConfig) -> list[CheckResult]: + """Doctor checks for Grok Build: root, OMI folder, MCP, hooks, skill, rules.""" + prov = GrokProvisioner(config=config, log=lambda _msg: None) + results = _diagnose_tools(prov.REQUIRED_TOOLS) + root = prov.agent_root() + if root.is_dir(): + results.append(CheckResult("grok_root", "ok", f"Grok Build found: {root}")) + else: + results.append( + CheckResult( + "grok_root", + "fail", + f"Grok config directory not found: {root} does not exist", + ) + ) + results.extend(_diagnose_omi_folder(config)) + name = config.server_name + try: + entry = prov.registered_mcp_entry() + except ProvisionError as exc: + results.append(CheckResult("grok_mcp_registration", "fail", str(exc))) + entry = None + if entry is None: + results.append( + CheckResult( + "grok_mcp_registration", + "fail", + f"MCP server '{name}' not in {grok_config_path()} " + "(run `omind setup --agent grok`)", + ) + ) + elif entry != prov.desired_mcp_entry(): + results.append( + CheckResult( + "grok_mcp_registration", + "warn", + f"MCP server '{name}' in {grok_config_path()} differs from expected wiring " + "(run `omind setup --agent grok`)", + ) + ) + else: + results.append( + CheckResult("grok_mcp_registration", "ok", f"MCP server '{name}' -> {config.omi_dir}") + ) + bad = _unreadable_config(prov._read_hooks) + if bad is not None: + results.append(CheckResult("grok_hooks", "fail", bad)) + elif prov._hooks_wired(): + results.append(CheckResult("grok_hooks", "ok", f"OMI hooks wired into {grok_hooks_path()}")) + else: + results.append( + CheckResult( + "grok_hooks", + "fail", + f"OMI hooks missing from {grok_hooks_path()} (run `omind setup --agent grok`)", + ) + ) + try: + hook_doc = prov._read_hooks() + except ProvisionError: + hook_doc = None + quoted = _go_cmd_quote_check( + "grok_cmd_quotes", "grok", _hook_commands_in(hook_doc), config.folder + ) + if quoted is not None: + results.append(quoted) + skill_file = prov.skill_dir() / paths.AGENT_SKILL_FILENAME + if skill_file.is_file(): + results.append(CheckResult("grok_skill", "ok", f"omind skill found: {skill_file}")) + else: + results.append( + CheckResult( + "grok_skill", + "fail", + f"omind skill missing from {skill_file} (run `omind setup --agent grok`)", + ) + ) + if prov.bootstrap_installed(): + results.append( + CheckResult("grok_bootstrap", "ok", f"OMI bootstrap pointer in {grok_rules_path()}") + ) + else: + results.append( + CheckResult( + "grok_bootstrap", + "fail", + f"OMI bootstrap pointer missing from {grok_rules_path()} " + "(run `omind setup --agent grok`)", + ) + ) + return results + + PROVISIONERS: dict[str, type[Provisioner]] = { "claude": Provisioner, "hermes": HermesProvisioner, @@ -3495,6 +3865,7 @@ def diagnose_agy(config: SetupConfig) -> list[CheckResult]: "goose": GooseProvisioner, "agy": AgyProvisioner, "antigravity": AgyProvisioner, + "grok": GrokProvisioner, } DIAGNOSERS = { @@ -3513,6 +3884,7 @@ def diagnose_agy(config: SetupConfig) -> list[CheckResult]: "goose": diagnose_goose, "agy": diagnose_agy, "antigravity": diagnose_agy, + "grok": diagnose_grok, } AGENT_CHOICES = tuple(PROVISIONERS) @@ -3520,7 +3892,7 @@ def diagnose_agy(config: SetupConfig) -> list[CheckResult]: def run_setup_for(config: SetupConfig, log: Logger = print) -> list[str]: """Run the provisioner for ``config.agent`` (claude, hermes, openclaw, opencode, - codex, gemini, deepseek, poolside, goose, agy, claude-desktop, kiro, vscode, q).""" + codex, gemini, deepseek, poolside, goose, agy, grok, claude-desktop, kiro, vscode, q).""" return PROVISIONERS[config.agent](config=config, log=log).run() diff --git a/src/omind/cli.py b/src/omind/cli.py index 7e6cd9d..e230130 100644 --- a/src/omind/cli.py +++ b/src/omind/cli.py @@ -5,7 +5,7 @@ Subcommands: * ``omind setup`` — provision the OMI/Obsidian MCP wiring for an AI agent (``--agent`` claude (default), hermes, openclaw, opencode, codex, gemini, - deepseek, poolside, goose, claude-desktop, kiro, vscode, q, agy). + deepseek, poolside, goose, claude-desktop, kiro, vscode, q, agy, grok). * ``omind serve`` — run the local web UI over an OMI memory folder. * ``omind doctor`` — diagnose the wiring. * ``omind export`` — write the entire OMI dataset to a json or tar.gz bundle. diff --git a/src/omind/embed.py b/src/omind/embed.py index d5e5bdc..04e0a1a 100644 --- a/src/omind/embed.py +++ b/src/omind/embed.py @@ -83,6 +83,11 @@ def _load_model2vec() -> Backend | None: no network, load error — records a reason and fails open to the keyword path. """ global _last_error + # Hugging Face prints "Fetching N files" on stderr while the model loads. + # A Grok PreToolUse hook that exits 2 shows that first stderr line as the + # deny reason, which hides the guard's actual text. Silence it before import. + os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1") + os.environ.setdefault("TQDM_DISABLE", "1") try: from model2vec import StaticModel except Exception as exc: # ImportError, or a broken partial install diff --git a/src/omind/harness.py b/src/omind/harness.py index 2ec6b90..723f3e5 100644 --- a/src/omind/harness.py +++ b/src/omind/harness.py @@ -39,6 +39,11 @@ FMT_POOLSIDE = "poolside" # Antigravity (agy) PreToolUse: {"decision":"deny","reason"} / {"decision":"allow"}, exit 0 FMT_AGY = "agy" +# Grok Build PreToolUse: {"decision":"deny","reason"} on stdout, exit 0. +# Exit 2 is also a deny, but Grok shows only the first stderr line as the +# reason, so a progress bar printed before the BLOCKED line hides the guard +# message. The JSON decision is honored regardless of exit code. +FMT_GROK = "grok" @dataclass(frozen=True) @@ -83,6 +88,11 @@ def can_block(self) -> bool: "antigravity": HarnessSpec( "antigravity", CAP_HARD_BLOCK, FMT_AGY, "Antigravity CLI PreToolUse hook" ), + # Grok Build (the `grok` CLI) speaks Claude-shaped hook names but a camelCase + # payload (`toolName`, `toolInput`, `sessionId`, `hookEventName`). It also + # runs Claude Code's hooks via compatibility and feeds them that same + # payload. PreToolUse hard-blocks on stdout `{"decision":"deny","reason"}`. + "grok": HarnessSpec("grok", CAP_HARD_BLOCK, FMT_GROK, "Grok Build PreToolUse hook"), # RETIRED (#425): the "POST /hooks/agent gateway" this described does not # exist. OpenClaw has no shell-command hooks (tool gating is the in-process # plugin hook before_tool_call) and rejects the hooks.agent entry omind used @@ -200,6 +210,16 @@ def render_decision( else: out.write(json.dumps({"decision": "deny", "reason": verdict.reason}) + "\n") return 0 + if fmt == FMT_GROK: + # Grok reads a top-level decision on stdout (exit 0). `deny` drops the + # call and `reason` is shown to the model. Empty stdout + exit 0 = allow. + # Do not also exit 2: a non-JSON crash must fail open, and exit 2 would + # surface the first stderr line instead of this reason. + if not verdict.allow: + out.write( + json.dumps({"decision": "deny", "reason": f"OMI guard: {verdict.reason}"}) + "\n" + ) + return 0 # FMT_EXIT2 (default): stderr reason + exit 2 — the Claude/shell contract. if not verdict.allow: err.write(f"BLOCKED by {verdict.reason}\n") @@ -300,10 +320,140 @@ def agy_last_prompt(transcript_path: object) -> str: return "" +#: Grok's ``hookEventName`` values (snake_case). Claude and Codex send PascalCase +#: on ``hook_event_name`` and do not set this key. +_GROK_HOOK_EVENTS = frozenset( + { + "pre_tool_use", + "post_tool_use", + "post_tool_use_failure", + "user_prompt_submit", + "session_start", + "session_end", + "stop", + "stop_failure", + "stop_cancelled", + "notification", + "subagent_start", + "subagent_stop", + "pre_compact", + "post_compact", + "permission_denied", + } +) +_GROK_EVENT_TO_CLAUDE = { + "pre_tool_use": "PreToolUse", + "post_tool_use": "PostToolUse", + "post_tool_use_failure": "PostToolUseFailure", + "user_prompt_submit": "UserPromptSubmit", + "session_start": "SessionStart", + "session_end": "SessionEnd", + "stop": "Stop", + "subagent_start": "SubagentStart", + "subagent_stop": "SubagentStop", + "pre_compact": "PreCompact", + "post_compact": "PostCompact", + "notification": "Notification", + "permission_denied": "PermissionDenied", +} +#: Grok's built-in tool names, mapped onto the names the guard policy already +#: classifies (shell, write, read). MCP tools are handled separately. +_GROK_TOOL_ALIASES = { + "run_terminal_command": "Bash", + "read_file": "Read", + "search_replace": "Edit", + "write": "Write", + "grep": "Grep", + "list_dir": "LS", + "web_search": "WebSearch", + "spawn_subagent": "Task", +} +_GROK_DISPATCHERS = frozenset({"use_tool", "CallMcpTool"}) +_GROK_BARE_OMI_TOOLS = frozenset( + { + "search-vault", + "recall-note", + "read-note", + "create-note", + "edit-note", + "list-notes", + "help", + } +) + + +def payload_is_grok(event: dict[str, Any]) -> bool: + """True when ``event`` is a Grok Build hook payload. + + Grok loads Claude Code's hooks and sends them this shape, so the check is + on the payload, not only on ``--harness grok``. Claude and Codex events + stay snake_case (``tool_name`` / ``tool_input``) and do not match. + """ + if not isinstance(event, dict): + return False + name = event.get("hookEventName") + if isinstance(name, str) and name in _GROK_HOOK_EVENTS: + return True + return "toolName" in event or "toolInput" in event + + +def _translate_grok(event: dict[str, Any]) -> dict[str, Any]: + """Map a Grok hook event onto the Claude-shaped event the rest of omind uses.""" + out = dict(event) + if not out.get("tool_name"): + name = out.get("toolName") + if isinstance(name, str): + out["tool_name"] = name + if not isinstance(out.get("tool_input"), dict) and isinstance(out.get("toolInput"), dict): + out["tool_input"] = dict(out["toolInput"]) + if not out.get("session_id") and isinstance(out.get("sessionId"), str): + out["session_id"] = out["sessionId"] + if not out.get("transcript_path") and isinstance(out.get("transcriptPath"), str): + out["transcript_path"] = out["transcriptPath"] + if not str(out.get("prompt") or "").strip(): + for key in ("userPrompt", "message"): + value = out.get(key) + if isinstance(value, str) and value.strip(): + out["prompt"] = value + break + if "tool_response" not in out and "toolResult" in out: + out["tool_response"] = out["toolResult"] + if not out.get("hook_event_name"): + mapped = _GROK_EVENT_TO_CLAUDE.get(str(out.get("hookEventName") or "")) + if mapped: + out["hook_event_name"] = mapped + + tool = str(out.get("tool_name") or "") + raw_input = out.get("tool_input") + tool_input: dict[str, Any] = raw_input if isinstance(raw_input, dict) else {} + if tool in _GROK_DISPATCHERS: + inner = tool_input.get("tool_name") or tool_input.get("toolName") or tool_input.get("name") + if isinstance(inner, str) and inner: + tool = inner + nested = ( + tool_input.get("tool_input") + or tool_input.get("arguments") + or tool_input.get("toolInput") + ) + if isinstance(nested, dict): + out["tool_input"] = dict(nested) + out["tool_name"] = tool + if tool in _GROK_TOOL_ALIASES: + tool = _GROK_TOOL_ALIASES[tool] + out["tool_name"] = tool + if tool.startswith("omi__") or ("__" in tool and not tool.startswith("mcp__")): + out["tool_name"] = "mcp__" + tool + elif tool.startswith("omi_"): + out["tool_name"] = "mcp__omi__" + tool[4:] + elif tool in _GROK_BARE_OMI_TOOLS: + out["tool_name"] = "mcp__omi__" + tool + return out + + def translate_event(harness: str, event: dict[str, Any]) -> dict[str, Any]: """Map a harness's raw hook event onto the Claude-shaped event the rest of omind consumes (journal, verifier, accounting, guard). Identity for every - harness but Poolside and Antigravity: + harness but Poolside, Antigravity, and Grok: - Poolside: MCP tools are ``__`` with no ``mcp__`` prefix, shell tool carries ``cmd``, ``PostToolUse`` carries ``tool_output``, @@ -311,9 +461,15 @@ def translate_event(harness: str, event: dict[str, Any]) -> dict[str, Any]: - Antigravity: toolCall carries ``name`` and ``args`` (with ``CommandLine`` / ``TargetFile`` / ``AbsolutePath``), session in ``conversationId``, prompt recovered from transcript. + - Grok: camelCase ``toolName`` / ``toolInput`` / ``sessionId``, built-in + tool names aliased onto Claude's, MCP tools ``__``. A + Grok-shaped payload is translated even when the hook was installed as + Claude's, because Grok runs those hooks with its own event shape. """ if not isinstance(event, dict): return event + if harness == "grok" or payload_is_grok(event): + return _translate_grok(event) spec = spec_for(harness) if spec.block_format == FMT_AGY: out = dict(event) @@ -508,6 +664,17 @@ def render_stop_block(harness: str, reason: str) -> str: }, True, ), + ( + "grok", + { + "hookEventName": "pre_tool_use", + "hook_event_name": "PreToolUse", + "sessionId": "st", + "toolName": "run_terminal_command", + "toolInput": {"command": "gh repo delete acme/widget"}, + }, + True, + ), ) diff --git a/src/omind/hooks.py b/src/omind/hooks.py index 27dc0be..8de2f6b 100644 --- a/src/omind/hooks.py +++ b/src/omind/hooks.py @@ -64,7 +64,7 @@ def command_is_omind_hook(command: str) -> bool: #: PreToolUse re-arm demand from the core, so nothing is lost — just nudged #: later. Declared here, not inferred from the event, because a phantom #: injection would be recorded as a consult the model never saw. -INJECTING_HARNESSES = frozenset({"claude"}) +INJECTING_HARNESSES = frozenset({"claude", "grok"}) #: Hermes Agent has no SessionStart hook; it fires ``pre_llm_call`` before every #: LLM turn and consumes a ``{"context": ...}`` payload on stdout. omind installs #: this event to inject the same priming the Claude SessionStart hook does — but diff --git a/src/omind/quickstart.py b/src/omind/quickstart.py index 9784d4d..ff7d647 100644 --- a/src/omind/quickstart.py +++ b/src/omind/quickstart.py @@ -15,6 +15,7 @@ import json +import tomlkit import yaml from omind import paths, seeds @@ -25,6 +26,7 @@ AmazonQProvisioner, ClaudeDesktopProvisioner, GooseProvisioner, + GrokProvisioner, HermesProvisioner, KiroProvisioner, McpOnlyProvisioner, @@ -36,6 +38,10 @@ agy_skill_dir, goose_config_path, goose_hints_path, + grok_config_path, + grok_hooks_path, + grok_rules_path, + grok_skill_dir, hermes_config_path, openclaw_config_path, ) @@ -355,6 +361,96 @@ def _build_agy_quickstart(config: SetupConfig) -> str: """ +def _grok_mcp_document(config: SetupConfig, prov: GrokProvisioner) -> tomlkit.TOMLDocument: + """The ``[mcp_servers.]`` table ``omind setup --agent grok`` writes.""" + desired = prov.desired_mcp_entry() + doc = tomlkit.document() + servers = tomlkit.table() + entry = tomlkit.table() + entry.add("command", desired["command"]) + entry.add("args", desired["args"]) + servers.add(config.server_name, entry) + doc.add("mcp_servers", servers) + return doc + + +def _build_grok_quickstart(config: SetupConfig) -> str: + """Manual steps for Grok Build: scaffold + mesh + MCP table + owned hook + file + home rule. The hook file is wholly owned; other files under + ``hooks/`` are left alone.""" + prov = GrokProvisioner(config=config, log=lambda _msg: None) + omi = config.omi_dir + scaffold_block, mesh_block = _scaffold_and_mesh_blocks(config) + config_path = grok_config_path() + hooks_path = grok_hooks_path() + rules_path = grok_rules_path() + skill_dir = grok_skill_dir() + + mcp_toml = tomlkit.dumps(_grok_mcp_document(config, prov)).rstrip() + mcp_block = f"```toml\n{mcp_toml}\n```" + hooks_json = json.dumps(prov.desired_hooks_document(), indent=2) + hooks_block = f"```json\n{hooks_json}\n```" + rules_block = f"```markdown\n{prov.bootstrap_content().rstrip()}\n```" + + return f"""\ +omind quickstart — manual {prov.AGENT_LABEL} wiring for {omi} + +Everything below is exactly what `omind setup --agent {config.agent}` would do +for you. Apply the steps you want by hand; each is independent and safe to +re-run. Prefer the automated path? Just run: + + omind setup --agent {config.agent} --vault "{config.vault}" --folder {config.folder} + +[1/5] Scaffold the memory folder +Create the folder and a minimal Obsidian config so it opens directly as a +vault (skip any file you already have): + +{scaffold_block} + +[2/5] Initialize the mesh node +Makes the folder a git working tree with omind's field-level merge driver, +mints this machine's node identity, and locks the folder to owner-only: + +{mesh_block} + +[3/5] Register the MCP server with {prov.AGENT_LABEL} +The server is omind's own node server (`omind node`) — no Node.js, npm, or +third-party MCP package involved. MERGE this table into {config_path} +(create the file if absent; don't replace other `[mcp_servers]` entries). +`$GROK_HOME` overrides `~/.grok` for every path below: + +{mcp_block} + +[4/5] Install OMI lifecycle hooks +Write this document to {hooks_path}. omind owns that file. Leave every other +JSON file in the same directory alone. `PreToolUse` hard-blocks; a deny is +`{{"decision":"deny","reason":…}}` on stdout: + +{hooks_block} + +[5/5] Prime Grok to read OMI first +Grok does not inject `SessionStart` stdout, so this rules file is the primer. +Write it to {rules_path} (the markers let a later `omind setup` manage only +this block): + +{rules_block} + +Install the packaged skill: +Run `omind setup --agent {config.agent}` or copy the packaged skill into: + {skill_dir} + +Verify the wiring (pure inspection, changes nothing): + + omind doctor --agent {config.agent} --vault "{config.vault}" --folder {config.folder} + +Then start a new Grok session to load the tools, hooks, skill, and rules. + +Undo: delete the '{config.server_name}' table from {config_path}, delete +{hooks_path}, and remove the marked block from {rules_path}. Your notes in +"{omi}" are never touched by any of this. +""" + + def build_quickstart(config: SetupConfig) -> str: """The full quickstart text for one vault/folder/server-name/agent combination.""" if config.agent in ("hermes", "openclaw"): @@ -363,6 +459,8 @@ def build_quickstart(config: SetupConfig) -> str: return _build_goose_quickstart(config) if config.agent in ("agy", "antigravity"): return _build_agy_quickstart(config) + if config.agent == "grok": + return _build_grok_quickstart(config) if config.agent in MCP_ONLY_PROVISIONERS: return _build_mcp_only_quickstart(config) prov = Provisioner(config=config, log=lambda _msg: None) diff --git a/src/omind/seeds.py b/src/omind/seeds.py index 2780a6e..f0a16e2 100644 --- a/src/omind/seeds.py +++ b/src/omind/seeds.py @@ -317,6 +317,48 @@ """ +# Managed home rule for Grok Build. Grok loads every Markdown file in +# ~/.grok/rules/ into the system prompt. SessionStart hook stdout is ignored, +# so this file — not the hook — is what primes a session. +GROK_RULES_TEMPLATE = """\ + +## OMI Bootstrap + +This file is managed by `omind setup --agent grok`. It is a bootstrap pointer, +not the source of truth. + +CryptoJones runs long-term assistant memory out of OMI. Use OMI for durable +preferences, persona, project memory, and "remember this" requests. Do not rely +on Grok's built-in memory as the only source for required behavior. + +- OMI MCP slug: `omi` +- Local vault root on this machine: `{vault}` +- OMI folder: `{folder}` +- OMI directory: `{omi_dir}` + +- **Read OMI first.** Before acting on a task, consult OMI. The `omi` MCP server + is wired up; use `search-vault` then token-bounded `recall-note`. The vault's + `index.md` lists recent memories. +- **Get live syntax.** For `/omind help` or CLI questions, call OMI MCP `help`; + do not rely on syntax embedded in this pointer. +- **Save memories through omind only.** When something is worth keeping across + sessions, persist it with the single-writer CLI (never write files into the + OMI folder directly — a raw write can corrupt the index): + + ```bash + omind note --title "Short Descriptive Title" \\ + --summary "one-line summary" --tags "topic,subtopic" \\ + --vault "{vault}" --folder "{folder}" <<'BODY' + The insight in plain Markdown, with [[wikilinks]] to related notes. + BODY + ``` + +If OMI and the user's explicit current instruction conflict, the current +instruction wins for that turn. If OMI is unavailable, proceed from this pointer +and say that OMI could not be read. + +""" + # Managed global AGENTS.md section for Antigravity CLI (agy). Antigravity discovers # AGENTS.md rules globally in ~/.gemini/config/AGENTS.md. AGY_BOOTSTRAP_START = "" diff --git a/tests/test_adapters.py b/tests/test_adapters.py index 4543e51..ed3d90d 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -514,7 +514,7 @@ def _rendered_deny(harness: str, code: int, out: str) -> bool: hook = data["hookSpecificOutput"] decision = hook.get("permissionDecision") or hook.get("decision", {}).get("behavior") return bool(decision == "deny") - if fmt in (harness_mod.FMT_GEMINI, harness_mod.FMT_AGY): + if fmt in (harness_mod.FMT_GEMINI, harness_mod.FMT_AGY, harness_mod.FMT_GROK): return bool(data.get("decision") == "deny") if fmt == harness_mod.FMT_POOLSIDE: return bool(data["hook_specific_output"]["permission_decision"] == "deny") diff --git a/tests/test_grok_client.py b/tests/test_grok_client.py new file mode 100644 index 0000000..d867b76 --- /dev/null +++ b/tests/test_grok_client.py @@ -0,0 +1,246 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Aaron K. Clark +"""Grok Build client: event translation and `omind setup --agent grok`.""" + +from __future__ import annotations + +import io +import json +from pathlib import Path + +import pytest +import tomlkit + +from omind import adapters, agents, harness, paths, seeds +from omind.agents import diagnose_grok, run_setup_for +from omind.provision import ProvisionError, SetupConfig + + +def test_grok_payload_translates_shell_and_consult() -> None: + shell = harness.translate_event( + "claude", + { + "hookEventName": "pre_tool_use", + "sessionId": "sess-1", + "toolName": "run_terminal_command", + "toolInput": {"command": "git status"}, + }, + ) + assert shell["tool_name"] == "Bash" + assert shell["tool_input"]["command"] == "git status" + assert shell["session_id"] == "sess-1" + assert shell["hook_event_name"] == "PreToolUse" + + consult = harness.translate_event( + "grok", + { + "hook_event_name": "PreToolUse", + "sessionId": "sess-1", + "toolName": "omi__search-vault", + "toolInput": {"query": "grok"}, + }, + ) + assert consult["tool_name"] == "mcp__omi__search-vault" + action = adapters.normalize_action(consult) + assert action["is_omi_consult"] is True + + write = harness.translate_event( + "grok", + {"toolName": "search_replace", "toolInput": {"path": "a.py"}}, + ) + assert write["tool_name"] == "Edit" + assert write["tool_input"]["path"] == "a.py" + + +def test_claude_event_is_not_treated_as_grok() -> None: + event = { + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": {"command": "ls"}, + "session_id": "s", + } + assert harness.payload_is_grok(event) is False + assert harness.translate_event("claude", event)["tool_name"] == "Bash" + + +def test_claude_hook_denies_grok_payload_as_json() -> None: + event = { + "hookEventName": "pre_tool_use", + "hook_event_name": "PreToolUse", + "sessionId": "st", + "toolName": "run_terminal_command", + "toolInput": {"command": "gh repo delete acme/widget"}, + } + stdout, stderr = io.StringIO(), io.StringIO() + # run_adapter writes the process streams; capture by patching is heavier + # than rendering the translated verdict the same way the selftest does. + translated = harness.translate_event("claude", event) + action = adapters.normalize_action(translated) + from omind import guard + + verdict = guard.decide(action) + code = harness.render_decision( + verdict, harness.FMT_GROK, stdout, stderr, event="PreToolUse" + ) + assert verdict.allow is False + assert code == 0 and stderr.getvalue() == "" + body = json.loads(stdout.getvalue()) + assert body["decision"] == "deny" + assert "OMI guard:" in body["reason"] + assert "gh repo delete" in body["reason"] or body["reason"] + + +def test_grok_dispatcher_lifts_inner_mcp_tool() -> None: + event = harness.translate_event( + "grok", + { + "toolName": "use_tool", + "toolInput": { + "tool_name": "omi__recall-note", + "tool_input": {"name": "Operational Rules - Git Repos and Secrets"}, + }, + }, + ) + assert event["tool_name"] == "mcp__omi__recall-note" + assert event["tool_input"]["name"].startswith("Operational Rules") + + +@pytest.fixture +def grok_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: + root = tmp_path / "grok-home" + root.mkdir() + monkeypatch.setattr(agents, "grok_config_dir", lambda: root) + return root + + +def _config(tmp_path: Path, **kw: object) -> SetupConfig: + return SetupConfig(vault=tmp_path / "vault", agent="grok", **kw) # type: ignore[arg-type] + + +def _quiet(_: str) -> None: + pass + + +def test_grok_config_dir_honors_grok_home( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("GROK_HOME", str(tmp_path / "custom")) + assert agents.grok_config_dir() == tmp_path / "custom" + + +def test_grok_setup_registers_mcp_hooks_skill_and_rules( + tmp_path: Path, grok_home: Path +) -> None: + config = _config(tmp_path) + run_setup_for(config, log=_quiet) + + doc = tomlkit.parse(agents.grok_config_path().read_text(encoding="utf-8")) + omi = doc["mcp_servers"]["omi"] + assert omi["command"] + assert list(omi["args"])[-4:] == ["node", "--vault", str(config.vault), "--folder"] or ( + "node" in list(omi["args"]) and str(config.vault) in list(omi["args"]) + ) + assert str(config.vault) in list(omi["args"]) + assert config.folder in list(omi["args"]) + + hooks = json.loads(agents.grok_hooks_path().read_text(encoding="utf-8"))["hooks"] + for event in agents.GROK_HOOK_EVENTS: + assert event in hooks + pre = hooks["PreToolUse"][0]["hooks"][0] + assert "guard adapter --harness grok" in pre["command"] + assert pre["timeout"] == 30 + assert "guard preflight --harness grok" in hooks["UserPromptSubmit"][0]["hooks"][0]["command"] + post = hooks["PostToolUse"][0]["hooks"][0]["command"] + assert "hook PostToolUse" in post and "--harness grok" in post + + skill = agents.grok_skill_dir() / paths.AGENT_SKILL_FILENAME + assert skill.is_file() + assert str(config.vault) in skill.read_text(encoding="utf-8") + + rules = agents.grok_rules_path().read_text(encoding="utf-8") + assert seeds.GROK_RULES_TEMPLATE.split("\n", 1)[0].strip() in rules or ( + "omind:grok-bootstrap:start" in rules and str(config.omi_dir) in rules + ) + assert "omind:grok-bootstrap:start" in rules + assert str(config.omi_dir) in rules + + +def test_grok_setup_is_idempotent(tmp_path: Path, grok_home: Path) -> None: + config = _config(tmp_path) + run_setup_for(config, log=_quiet) + before = { + "toml": agents.grok_config_path().read_text(encoding="utf-8"), + "hooks": agents.grok_hooks_path().read_text(encoding="utf-8"), + "rules": agents.grok_rules_path().read_text(encoding="utf-8"), + } + actions = run_setup_for(config, log=_quiet) + assert agents.grok_config_path().read_text(encoding="utf-8") == before["toml"] + assert agents.grok_hooks_path().read_text(encoding="utf-8") == before["hooks"] + assert agents.grok_rules_path().read_text(encoding="utf-8") == before["rules"] + assert before["rules"].count("omind:grok-bootstrap:start") == 1 + assert not any("register MCP" in a for a in actions) + + +def test_grok_setup_preserves_foreign_content(tmp_path: Path, grok_home: Path) -> None: + agents.grok_config_path().write_text( + '# keep me\nmodel = "grok"\n\n[mcp_servers.other]\ncommand = "/bin/other"\nargs = ["x"]\n', + encoding="utf-8", + ) + (grok_home / "hooks").mkdir() + (grok_home / "hooks" / "mine.json").write_text('{"hooks": {}}\n', encoding="utf-8") + agents.grok_rules_path().parent.mkdir(parents=True) + agents.grok_rules_path().write_text("# my note\nBe brief.\n", encoding="utf-8") + + run_setup_for(_config(tmp_path), log=_quiet) + + text = agents.grok_config_path().read_text(encoding="utf-8") + assert "# keep me" in text + assert 'model = "grok"' in text + doc = tomlkit.parse(text) + assert "other" in doc["mcp_servers"] + assert "omi" in doc["mcp_servers"] + assert (grok_home / "hooks" / "mine.json").read_text(encoding="utf-8") == '{"hooks": {}}\n' + rules = agents.grok_rules_path().read_text(encoding="utf-8") + assert "Be brief." in rules + assert rules.count("omind:grok-bootstrap:start") == 1 + + +def test_grok_setup_refuses_corrupt_toml(tmp_path: Path, grok_home: Path) -> None: + agents.grok_config_path().write_text("this = [\n", encoding="utf-8") + with pytest.raises(ProvisionError): + run_setup_for(_config(tmp_path), log=_quiet) + assert agents.grok_config_path().read_text(encoding="utf-8") == "this = [\n" + + +def test_grok_setup_fails_without_install( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + missing = tmp_path / "no-grok" + monkeypatch.setattr(agents, "grok_config_dir", lambda: missing) + with pytest.raises(ProvisionError): + run_setup_for(_config(tmp_path), log=_quiet) + + +def test_grok_dry_run_writes_nothing(tmp_path: Path, grok_home: Path) -> None: + actions = run_setup_for(_config(tmp_path, dry_run=True), log=_quiet) + assert any("register MCP" in a for a in actions) + assert not agents.grok_config_path().exists() + assert not agents.grok_hooks_path().exists() + assert not agents.grok_rules_path().exists() + + +def test_diagnose_grok_reports_state(tmp_path: Path, grok_home: Path) -> None: + config = _config(tmp_path) + before = {c.key: c.level for c in diagnose_grok(config)} + assert before["grok_root"] == "ok" + assert before["grok_mcp_registration"] == "fail" + assert before["grok_hooks"] == "fail" + assert before["grok_skill"] == "fail" + assert before["grok_bootstrap"] == "fail" + + run_setup_for(config, log=_quiet) + after = {c.key: c.level for c in diagnose_grok(config)} + assert after["grok_mcp_registration"] == "ok" + assert after["grok_hooks"] == "ok" + assert after["grok_skill"] == "ok" + assert after["grok_bootstrap"] == "ok" diff --git a/tests/test_harness.py b/tests/test_harness.py index 4dd257e..caed5ca 100644 --- a/tests/test_harness.py +++ b/tests/test_harness.py @@ -20,6 +20,7 @@ def test_specs_and_fallback() -> None: assert harness.spec_for("openclaw").block_format == harness.FMT_OPENCLAW assert harness.spec_for("agy").block_format == harness.FMT_AGY assert harness.spec_for("antigravity").block_format == harness.FMT_AGY + assert harness.spec_for("grok").block_format == harness.FMT_GROK assert harness.spec_for("unknown-harness").name == "claude" # safe fallback # Gemini's BeforeTool hook hard-blocks; OpenClaw is detect-only until a live # gateway is confirmed to enforce a deny (issue #88). @@ -139,6 +140,7 @@ def test_selftest_all_pass() -> None: "poolside", "openclaw", "agy", + "grok", } assert all(r["ok"] for r in results) assert all(r["blocked"] for r in results) # every canned case is a hard rule @@ -155,6 +157,9 @@ def test_selftest_all_pass() -> None: assert by["openclaw"]["format"] == harness.FMT_OPENCLAW assert by["agy"]["format"] == harness.FMT_AGY assert '"decision": "deny"' in by["agy"]["rendered"] + assert by["grok"]["format"] == harness.FMT_GROK + assert '"decision": "deny"' in by["grok"]["rendered"] + assert by["grok"]["command"] == "gh repo delete acme/widget" def test_run_guard_selftest_action() -> None: diff --git a/tests/test_quickstart.py b/tests/test_quickstart.py index aa3453e..15e748c 100644 --- a/tests/test_quickstart.py +++ b/tests/test_quickstart.py @@ -173,3 +173,47 @@ def test_agy_quickstart_matches_provisioner(tmp_path: Path) -> None: out_alias = build_quickstart(config_alias) assert 'omind setup --agent antigravity --vault "' in out_alias + +def test_grok_quickstart_matches_provisioner(tmp_path: Path) -> None: + import tomlkit + + from omind.agents import ( + GROK_BOOTSTRAP_END, + GROK_BOOTSTRAP_START, + GrokProvisioner, + grok_config_path, + grok_hooks_path, + grok_rules_path, + grok_skill_dir, + ) + + config = SetupConfig(vault=tmp_path / "Vault", folder="OMI", agent="grok") + out = build_quickstart(config) + + assert 'omind setup --agent grok --vault "' in out + assert "omind doctor --agent grok" in out + assert str(grok_config_path()) in out + assert str(grok_hooks_path()) in out + assert str(grok_rules_path()) in out + assert str(grok_skill_dir()) in out + assert "claude mcp add" not in out + + prov = GrokProvisioner(config=config, log=lambda _m: None) + + toml_blocks = _fenced(out, "toml") + assert len(toml_blocks) == 1 + parsed = tomlkit.parse(toml_blocks[0]) + got = parsed["mcp_servers"][config.server_name] + desired = prov.desired_mcp_entry() + assert got["command"] == desired["command"] + assert list(got["args"]) == desired["args"] + + json_blocks = _fenced(out, "json") + assert len(json_blocks) == 1 + assert json.loads(json_blocks[0]) == prov.desired_hooks_document() + + md_blocks = _fenced(out, "markdown") + assert len(md_blocks) == 1 + assert GROK_BOOTSTRAP_START in md_blocks[0] and GROK_BOOTSTRAP_END in md_blocks[0] + assert str(config.omi_dir) in md_blocks[0] + diff --git a/uv.lock b/uv.lock index d5b7972..bef32e6 100644 --- a/uv.lock +++ b/uv.lock @@ -2364,7 +2364,7 @@ wheels = [ [[package]] name = "omind" -version = "10.2.28" +version = "10.3.0" source = { editable = "." } dependencies = [ { name = "cryptography" }, From 864f6fd3237bd121c2e5af08558173a4a19551b2 Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark" Date: Mon, 5 Oct 2026 07:14:03 -0500 Subject: [PATCH 2/2] fix(grok): refuse to overwrite an unreadable hooks or rules file install_hooks treated a ProvisionError from a bad hooks file as absent and replaced it. install_bootstrap treated any read error, including invalid UTF-8, as a missing file and replaced the rules. A missing file is still created. Bad JSON and undecodable text raise ProvisionError and stay on disk. Co-authored-by: Grok 4.7 --- CHANGELOG.md | 7 +++++++ src/omind/agents.py | 13 ++++++++----- tests/test_grok_client.py | 40 ++++++++++++++++++++++++++++----------- 3 files changed, 44 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e647a2..8f2aa92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 shows. The embedding model load no longer prints a Hugging Face progress bar onto that channel. +### Fixed +- **Grok setup refuses to replace a hooks or rules file it cannot read.** + Invalid JSON or an unreadable `~/.grok/hooks/omind.json` raises + `ProvisionError` instead of being treated as missing. A rules file that + exists but cannot be decoded is left in place; a missing rules file is + still created. + ## [10.2.28] - 2026-10-02 ### Fixed diff --git a/src/omind/agents.py b/src/omind/agents.py index e4151fa..126c6c9 100644 --- a/src/omind/agents.py +++ b/src/omind/agents.py @@ -3667,10 +3667,9 @@ def _hooks_wired(self) -> bool: def install_hooks(self) -> None: path = grok_hooks_path() desired = self.desired_hooks_document() - try: - current = self._read_hooks() - except ProvisionError: - current = None + # A missing file is {}. Bad JSON or an unreadable file raises, and + # that error must propagate: treating it as absent would overwrite it. + current = self._read_hooks() if current == desired and not self.config.force: self.log(f" OMI hooks already installed in {path}") return @@ -3701,8 +3700,12 @@ def install_bootstrap(self) -> None: desired = self.bootstrap_content() try: current = path.read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError): + except FileNotFoundError: current = "" + except (OSError, UnicodeDecodeError) as exc: + raise ProvisionError( + f"Could not read {path}; refusing to overwrite it." + ) from exc start = current.find(GROK_BOOTSTRAP_START) end = current.find(GROK_BOOTSTRAP_END) if start >= 0 and end >= start: diff --git a/tests/test_grok_client.py b/tests/test_grok_client.py index d867b76..5852ae4 100644 --- a/tests/test_grok_client.py +++ b/tests/test_grok_client.py @@ -11,7 +11,7 @@ import pytest import tomlkit -from omind import adapters, agents, harness, paths, seeds +from omind import adapters, agents, harness, paths from omind.agents import diagnose_grok, run_setup_for from omind.provision import ProvisionError, SetupConfig @@ -86,8 +86,8 @@ def test_claude_hook_denies_grok_payload_as_json() -> None: assert code == 0 and stderr.getvalue() == "" body = json.loads(stdout.getvalue()) assert body["decision"] == "deny" - assert "OMI guard:" in body["reason"] - assert "gh repo delete" in body["reason"] or body["reason"] + assert body["reason"].startswith("OMI guard: ") + assert "never delete a repo" in body["reason"] def test_grok_dispatcher_lifts_inner_mcp_tool() -> None: @@ -137,11 +137,13 @@ def test_grok_setup_registers_mcp_hooks_skill_and_rules( doc = tomlkit.parse(agents.grok_config_path().read_text(encoding="utf-8")) omi = doc["mcp_servers"]["omi"] assert omi["command"] - assert list(omi["args"])[-4:] == ["node", "--vault", str(config.vault), "--folder"] or ( - "node" in list(omi["args"]) and str(config.vault) in list(omi["args"]) - ) - assert str(config.vault) in list(omi["args"]) - assert config.folder in list(omi["args"]) + assert list(omi["args"])[-5:] == [ + "node", + "--vault", + str(config.vault), + "--folder", + config.folder, + ] hooks = json.loads(agents.grok_hooks_path().read_text(encoding="utf-8"))["hooks"] for event in agents.GROK_HOOK_EVENTS: @@ -158,9 +160,6 @@ def test_grok_setup_registers_mcp_hooks_skill_and_rules( assert str(config.vault) in skill.read_text(encoding="utf-8") rules = agents.grok_rules_path().read_text(encoding="utf-8") - assert seeds.GROK_RULES_TEMPLATE.split("\n", 1)[0].strip() in rules or ( - "omind:grok-bootstrap:start" in rules and str(config.omi_dir) in rules - ) assert "omind:grok-bootstrap:start" in rules assert str(config.omi_dir) in rules @@ -212,6 +211,25 @@ def test_grok_setup_refuses_corrupt_toml(tmp_path: Path, grok_home: Path) -> Non assert agents.grok_config_path().read_text(encoding="utf-8") == "this = [\n" +def test_grok_setup_refuses_unreadable_hooks(tmp_path: Path, grok_home: Path) -> None: + path = agents.grok_hooks_path() + path.parent.mkdir(parents=True) + path.write_text("{not json", encoding="utf-8") + with pytest.raises(ProvisionError): + run_setup_for(_config(tmp_path), log=_quiet) + assert path.read_text(encoding="utf-8") == "{not json" + + +def test_grok_setup_refuses_undecodable_rules(tmp_path: Path, grok_home: Path) -> None: + path = agents.grok_rules_path() + path.parent.mkdir(parents=True) + original = b"\xff\xfe not utf-8" + path.write_bytes(original) + with pytest.raises(ProvisionError): + run_setup_for(_config(tmp_path), log=_quiet) + assert path.read_bytes() == original + + def test_grok_setup_fails_without_install( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: