@@ -143,6 +143,25 @@ def codex_hooks_path() -> Path:
143143CODEX_GUARD_MARKER = "guard adapter --harness codex"
144144
145145
146+ def gemini_config_dir () -> Path :
147+ """Gemini CLI's config directory: ``$GEMINI_HOME`` or ``~/.gemini``."""
148+ base = os .environ .get ("GEMINI_HOME" )
149+ return Path (base ) if base else Path .home () / ".gemini"
150+
151+
152+ def gemini_settings_path () -> Path :
153+ """Gemini CLI's settings file (``hooks`` + ``mcpServers`` both live here)."""
154+ return gemini_config_dir () / "settings.json"
155+
156+
157+ #: Substring identifying omind's own Gemini guard hook command inside the user's
158+ #: settings.json, so a re-run replaces only our entry and never duplicates it.
159+ GEMINI_GUARD_MARKER = "guard adapter --harness gemini"
160+
161+ #: Substring identifying omind's own OpenClaw guard gateway hook in openclaw.json.
162+ OPENCLAW_GUARD_MARKER = "guard adapter --harness openclaw"
163+
164+
146165# -- shared agent machinery ---------------------------------------------------
147166
148167
@@ -590,6 +609,158 @@ def install_priming(self) -> None:
590609 path .parent .mkdir (parents = True , exist_ok = True )
591610 path .write_text (json .dumps (data , indent = 2 ) + "\n " , encoding = "utf-8" )
592611
612+ def integrate (self ) -> None :
613+ super ().integrate ()
614+ self .install_guard ()
615+
616+ def install_guard (self ) -> None :
617+ """Register the OMI guard as an OpenClaw gateway hook in ``openclaw.json``.
618+
619+ OpenClaw's hook transport is an HTTP/WebSocket gateway (POST /hooks/agent
620+ on :18789, loopback), not a stdout shell hook — so we register a command
621+ entry the gateway invokes as ``omind guard adapter --harness openclaw``;
622+ the adapter emits an ``{"allow","reason","rule_id"}`` verdict the gateway
623+ reads. Until that gateway is confirmed to ENFORCE a deny against a live
624+ instance, OpenClaw is wired DETECT-ONLY (issue #88) and the verdict is
625+ advisory. Touches only our own entry (by :data:`OPENCLAW_GUARD_MARKER`),
626+ preserving any user-authored hooks.
627+ """
628+ path = openclaw_config_path ()
629+ data = self ._read_settings (path )
630+ command = f"{ shutil .which ('omind' ) or 'omind' } guard adapter --harness openclaw"
631+ desired = {"event" : "pre_tool" , "command" : command , "enabled" : True }
632+ hooks = data .get ("hooks" )
633+ if not isinstance (hooks , dict ):
634+ hooks = {}
635+ agent_hooks = hooks .get ("agent" )
636+ existing = agent_hooks if isinstance (agent_hooks , list ) else []
637+ kept = [
638+ e
639+ for e in existing
640+ if not (isinstance (e , dict ) and OPENCLAW_GUARD_MARKER in json .dumps (e ))
641+ ]
642+ merged = kept + [desired ]
643+ if merged != existing or self .config .force :
644+ hooks ["agent" ] = merged
645+ data ["hooks" ] = hooks
646+ self ._record (f"register OMI guard gateway hook (detect-only) in { path } " )
647+ if not self .config .dry_run :
648+ path .parent .mkdir (parents = True , exist_ok = True )
649+ path .write_text (json .dumps (data , indent = 2 ) + "\n " , encoding = "utf-8" )
650+ else :
651+ self .log (f" OMI guard gateway hook already installed in { path } " )
652+
653+ def _guard_wired (self ) -> bool :
654+ try :
655+ data = self ._read_settings (openclaw_config_path ())
656+ except ProvisionError :
657+ return False
658+ hooks = data .get ("hooks" )
659+ agent_hooks = hooks .get ("agent" ) if isinstance (hooks , dict ) else None
660+ return any (
661+ isinstance (e , dict ) and OPENCLAW_GUARD_MARKER in json .dumps (e )
662+ for e in (agent_hooks or [])
663+ )
664+
665+
666+ # -- Gemini CLI -----------------------------------------------------------------
667+
668+
669+ class GeminiProvisioner (AgentProvisioner ):
670+ """Wire the OMI guard into the Google Gemini CLI via its ``BeforeTool`` hook.
671+
672+ Gemini CLI's hooks live under a top-level ``hooks`` key in
673+ ``~/.gemini/settings.json``. ``BeforeTool`` is the PreToolUse analog and can
674+ HARD-BLOCK: omind mounts ``omind guard adapter --harness gemini`` matching
675+ every tool (``matcher: ".*"``). On a deny the adapter prints
676+ ``{"decision":"deny","reason":...}`` on stdout (exit 0), which Gemini enforces
677+ as a tool block.
678+
679+ Guard-only: Gemini MCP-memory registration (``mcpServers`` in the same file)
680+ is a separate concern and intentionally not bundled here.
681+ """
682+
683+ AGENT_LABEL = "Gemini CLI"
684+ INSTALL_HINT = "Install the Gemini CLI (`npm i -g @google/gemini-cli`), then re-run."
685+ DONE_MESSAGE = (
686+ "Done. Restart the Gemini CLI to load the OMI guard "
687+ "(needs a Gemini CLI with BeforeTool hook support)."
688+ )
689+
690+ def agent_root (self ) -> Path :
691+ return gemini_config_dir ()
692+
693+ def integrate (self ) -> None :
694+ # Guard-only wiring (no MCP/skill/priming — see the class docstring).
695+ self .install_guard ()
696+
697+ def _guard_hook_group (self ) -> dict [str , Any ]:
698+ """One ``BeforeTool`` matcher group running the omind gemini adapter on
699+ every tool. Gemini pipes the event JSON on stdin; the adapter reads it."""
700+ omind = shutil .which ("omind" ) or "omind"
701+ return {
702+ "matcher" : ".*" ,
703+ "hooks" : [
704+ {
705+ "type" : "command" ,
706+ "command" : f"{ omind } guard adapter --harness gemini" ,
707+ "name" : "omind-omi-guard" ,
708+ "timeout" : 30000 ,
709+ }
710+ ],
711+ }
712+
713+ def install_guard (self ) -> None :
714+ """Merge omind's guard hook into ``~/.gemini/settings.json`` under
715+ ``hooks.BeforeTool``, replacing only our own entry (by
716+ :data:`GEMINI_GUARD_MARKER`) so user-authored hooks are preserved."""
717+ path = gemini_settings_path ()
718+ data = self ._read_settings (path )
719+ desired = self ._guard_hook_group ()
720+ hooks = data .get ("hooks" )
721+ if not isinstance (hooks , dict ):
722+ hooks = {}
723+ groups = hooks .get ("BeforeTool" )
724+ existing = groups if isinstance (groups , list ) else []
725+ kept = [
726+ g
727+ for g in existing
728+ if not (isinstance (g , dict ) and GEMINI_GUARD_MARKER in json .dumps (g ))
729+ ]
730+ merged = kept + [desired ]
731+ if merged != existing or self .config .force :
732+ hooks ["BeforeTool" ] = merged
733+ data ["hooks" ] = hooks
734+ self ._record (f"install OMI guard hook (BeforeTool) in { path } " )
735+ if not self .config .dry_run :
736+ path .parent .mkdir (parents = True , exist_ok = True )
737+ path .write_text (json .dumps (data , indent = 2 ) + "\n " , encoding = "utf-8" )
738+ else :
739+ self .log (f" OMI guard hook already installed in { path } " )
740+
741+ def _guard_wired (self ) -> bool :
742+ try :
743+ data = self ._read_settings (gemini_settings_path ())
744+ except ProvisionError :
745+ return False
746+ hooks = data .get ("hooks" )
747+ groups = hooks .get ("BeforeTool" ) if isinstance (hooks , dict ) else None
748+ return any (
749+ isinstance (g , dict ) and GEMINI_GUARD_MARKER in json .dumps (g )
750+ for g in (groups or [])
751+ )
752+
753+ def verify (self ) -> None :
754+ if self .config .dry_run :
755+ return
756+ if self ._guard_wired ():
757+ self .log (f" verified: OMI guard wired into Gemini CLI ({ gemini_settings_path ()} )" )
758+ else :
759+ self .log (
760+ " NOTE: could not confirm the OMI guard in Gemini's settings.json; "
761+ "re-run with --force."
762+ )
763+
593764
594765# -- OpenCode -------------------------------------------------------------------
595766
@@ -848,7 +1019,25 @@ def diagnose_hermes(config: SetupConfig) -> list[CheckResult]:
8481019
8491020
8501021def diagnose_openclaw (config : SetupConfig ) -> list [CheckResult ]:
851- return _diagnose_agent (OpenClawProvisioner (config = config , log = lambda _msg : None ))
1022+ prov = OpenClawProvisioner (config = config , log = lambda _msg : None )
1023+ results = _diagnose_agent (prov )
1024+ if prov ._guard_wired ():
1025+ results .append (
1026+ CheckResult (
1027+ "openclaw_guard" ,
1028+ "ok" ,
1029+ f"OMI guard (detect-only) wired into { openclaw_config_path ()} " ,
1030+ )
1031+ )
1032+ else :
1033+ results .append (
1034+ CheckResult (
1035+ "openclaw_guard" ,
1036+ "warn" ,
1037+ "OMI guard not in openclaw.json (run `omind setup --agent openclaw`)" ,
1038+ )
1039+ )
1040+ return results
8521041
8531042
8541043def diagnose_opencode (config : SetupConfig ) -> list [CheckResult ]:
@@ -887,6 +1076,34 @@ def diagnose_codex(config: SetupConfig) -> list[CheckResult]:
8871076 return results
8881077
8891078
1079+ def diagnose_gemini (config : SetupConfig ) -> list [CheckResult ]:
1080+ """Gemini is guard-only here (no MCP/skill), so its doctor checks the
1081+ settings.json ``BeforeTool`` guard wiring rather than MCP registration."""
1082+ prov = GeminiProvisioner (config = config , log = lambda _msg : None )
1083+ results = _diagnose_tools (prov .REQUIRED_TOOLS )
1084+ root = gemini_config_dir ()
1085+ if root .is_dir ():
1086+ results .append (CheckResult ("gemini_root" , "ok" , f"Gemini CLI found: { root } " ))
1087+ else :
1088+ results .append (
1089+ CheckResult ("gemini_root" , "fail" , f"Gemini CLI not found: { root } does not exist" )
1090+ )
1091+ results .extend (_diagnose_omi_folder (prov .config ))
1092+ if prov ._guard_wired ():
1093+ results .append (
1094+ CheckResult ("gemini_guard" , "ok" , f"OMI guard wired into { gemini_settings_path ()} " )
1095+ )
1096+ else :
1097+ results .append (
1098+ CheckResult (
1099+ "gemini_guard" ,
1100+ "fail" ,
1101+ "OMI guard not in Gemini settings.json (run `omind setup --agent gemini`)" ,
1102+ )
1103+ )
1104+ return results
1105+
1106+
8901107# -- dispatch -------------------------------------------------------------------
8911108
8921109PROVISIONERS : dict [str , type [Provisioner ]] = {
@@ -895,6 +1112,7 @@ def diagnose_codex(config: SetupConfig) -> list[CheckResult]:
8951112 "openclaw" : OpenClawProvisioner ,
8961113 "opencode" : OpenCodeProvisioner ,
8971114 "codex" : CodexProvisioner ,
1115+ "gemini" : GeminiProvisioner ,
8981116}
8991117
9001118DIAGNOSERS = {
@@ -903,6 +1121,7 @@ def diagnose_codex(config: SetupConfig) -> list[CheckResult]:
9031121 "openclaw" : diagnose_openclaw ,
9041122 "opencode" : diagnose_opencode ,
9051123 "codex" : diagnose_codex ,
1124+ "gemini" : diagnose_gemini ,
9061125}
9071126
9081127AGENT_CHOICES = tuple (PROVISIONERS )
0 commit comments