"""Kimi Code hook commands for the native Omnigent wrapper. Registered into a per-session ``config.toml`` ``[[hooks]]`` array (see :mod:`omnigent.kimi_native_credentials`) so the running ``kimi`` TUI invokes them. Kimi spawns each hook with ``shell: true``, feeds the event JSON on stdin, and reads the decision back from stdout as ``{"hookSpecificOutput": {"permissionDecision": ..., "permissionDecisionReason": ...}}`` (``permissionDecision == "deny"`` blocks the tool). Two subcommands: - ``evaluate-policy`` — the ``PreToolUse`` deny-gate. Mirrors :func:`omnigent.claude_native_hook._main_evaluate_policy`: it converts the Kimi hook payload into an Omnigent ``EvaluationRequest`` (the snake-cased Kimi fields ``tool_name`` / ``tool_input`` / ``hook_event_name`` line up with :func:`omnigent.native_policy_hook.hook_payload_to_evaluation_request`), POSTs to ``/v1/sessions/{id}/policies/evaluate``, and emits a ``deny`` only for a constraining ``POLICY_ACTION_DENY`` verdict. ``ALLOW`` (the engine's no-match default) emits nothing, so kimi's own in-TUI approval prompt still runs — Omnigent enforces its deny-policy without silencing the user's consent. Fails CLOSED (deny) when an already-governed session can't reach a verdict, matching the claude-native gate. - ``permission-request`` — the interactive web-UI approval. Kimi fires ``PermissionRequest`` fire-and-forget (it does NOT read this hook's output — approval is answered by kimi's own TUI menu), so the hook cannot return an honored decision. Instead it drives a real web-UI Approve/Deny: it POSTs the gated tool to ``/v1/sessions/{id}/hooks/permission-request`` (the same endpoint claude-native uses — the server publishes the approval card and long-polls for the web verdict), then types the answer back into kimi's prompt via ``inject_approval_keystroke`` (option digit + Enter: :data:`~omnigent.kimi_native_bridge.APPROVE_KEY` "Approve once" / :data:`~omnigent.kimi_native_bridge.DENY_KEY` "Reject"). Fail-safe: on no verdict (timeout / unreachable / already answered in the terminal) it injects nothing and kimi's own TUI prompt stands. Never blocks the TUI. """ from __future__ import annotations import argparse import json import secrets import sys import urllib.parse from pathlib import Path import httpx from omnigent.kimi_native_bridge import ( APPROVE_KEY, DENY_KEY, inject_approval_keystroke, read_active_session_id, read_hook_config, ) from omnigent.native_policy_hook import ( evaluation_response_to_hook_output, fail_closed_hook_output, hook_payload_to_evaluation_request, policy_hook_reauth, post_evaluate_with_retry, ) # PreToolUse evaluations are normally a quick request/reply. (Unlike # claude-native, a TOOL_CALL ASK does NOT park here — kimi owns the ask via # its own TUI prompt, so the policy layer only ever DENY/ALLOWs for kimi.) _EVALUATE_POLICY_TIMEOUT_S = 70.0 # Short timeout for the keystroke-injection tmux round-trip; never delay the TUI. _SURFACE_TIMEOUT_S = 10.0 # Long-poll budget for the web approval verdict — the human may take a while. # On timeout the server returns an empty 200 and we fall back to kimi's own TUI # prompt (manual approval in the terminal). _PERMISSION_REQUEST_TIMEOUT_S = 3600.0 _HARNESS = "kimi-native" def _url_component(value: str) -> str: """Percent-encode one URL path component (slashes escaped).""" return urllib.parse.quote(value, safe="") def _headers_from_config(config: dict[str, object]) -> dict[str, str]: """Extract replayable auth headers from the bridge hook config.""" raw = config.get("ap_auth_headers") if not isinstance(raw, dict): return {} return {str(key): str(value) for key, value in raw.items()} def _read_stdin_payload() -> dict[str, object] | None: """Parse the hook event JSON from stdin; ``None`` when unusable.""" raw = sys.stdin.read() try: payload = json.loads(raw or "{}") except json.JSONDecodeError as exc: print(f"omnigent kimi hook: malformed JSON: {exc}", file=sys.stderr) return None if not isinstance(payload, dict): print("omnigent kimi hook: expected JSON object", file=sys.stderr) return None return payload def _main_evaluate_policy(argv: list[str]) -> int: """Evaluate a kimi ``PreToolUse`` hook against Omnigent policies. Reads the hook payload from stdin, POSTs an ``EvaluationRequest`` to ``/v1/sessions/{id}/policies/evaluate``, and writes kimi's hook decision to stdout. Only ``POLICY_ACTION_DENY`` produces a ``deny``; everything else emits nothing ("no opinion") so kimi's own approval prompt still fires. An already-governed session that cannot obtain a verdict fails CLOSED with a ``deny`` (this hook is the sole Omnigent enforcement point for kimi tool calls). :param argv: CLI argv after the ``evaluate-policy`` subcommand. :returns: Always ``0`` — verdicts are expressed via JSON, not exit codes. """ args = _parse_bridge_dir_args(argv, "evaluate-policy") payload = _read_stdin_payload() if payload is None: return 0 bridge_dir = Path(args.bridge_dir) session_id = read_active_session_id(bridge_dir) if not session_id: return 0 # not a governed session — no opinion config = read_hook_config(bridge_dir) ap_server_url = config.get("ap_server_url") if not isinstance(ap_server_url, str) or not ap_server_url: return 0 headers = _headers_from_config(config) hook_event = payload.get("hook_event_name", "") if not isinstance(hook_event, str): return 0 eval_request = hook_payload_to_evaluation_request(hook_event, payload) if eval_request is None: # Unrecognized event or an mcp__omnigent__* tool already gated on the # relay path — no policy to evaluate here. return 0 # hook_payload_to_evaluation_request always returns an event with a # "context" dict; index it directly (fail loud if that contract changes). context = eval_request["event"]["context"] context["harness"] = _HARNESS reauth = policy_hook_reauth(ap_server_url, headers) def _fail_closed(detail: str | None = None) -> int: out = fail_closed_hook_output(hook_event, detail) if out is not None: sys.stdout.write(json.dumps(out)) return 0 url = f"{ap_server_url.rstrip('/')}/v1/sessions/{_url_component(session_id)}/policies/evaluate" resp, api_error = post_evaluate_with_retry( url, headers, eval_request, _EVALUATE_POLICY_TIMEOUT_S, "kimi evaluate-policy hook", # Re-mint the baked one-shot token if it lapses mid-session. reauth=reauth, ) if resp is None or not resp.content: return _fail_closed(api_error or reauth.failure_reason) try: eval_response = resp.json() except json.JSONDecodeError: print("omnigent kimi evaluate-policy hook: malformed Omnigent response", file=sys.stderr) return _fail_closed() hook_output = evaluation_response_to_hook_output(hook_event, eval_response) if hook_output is not None: sys.stdout.write(json.dumps(hook_output)) return 0 def _main_permission_request(argv: list[str]) -> int: """Mirror a kimi ``PermissionRequest`` to the web UI and inject the verdict. Kimi fires this hook **fire-and-forget** — it answers approval in its own TUI and does NOT read the hook's stdout — so we cannot return a decision it honors. Instead we drive an interactive web-UI approval and type the answer back into kimi's prompt: 1. POST the gated tool to ``/v1/sessions/{id}/hooks/permission-request`` — the server publishes the standard ``response.elicitation_request`` approval card and long-polls for the web verdict (the very endpoint claude-native uses). 2. On ``allow`` / ``deny``, inject the matching kimi permission-menu option digit + Enter into the TUI pane via :func:`inject_approval_keystroke` (:data:`APPROVE_KEY` "Approve once" / :data:`DENY_KEY` "Reject"). Fail-safe: on no verdict (timeout / server unreachable / the prompt was already answered in the terminal) it injects nothing and kimi's own TUI prompt stands for manual approval. Always returns 0 (kimi ignores output). :param argv: CLI argv after the ``permission-request`` subcommand. :returns: Always ``0``. """ args = _parse_bridge_dir_args(argv, "permission-request") payload = _read_stdin_payload() if payload is None: return 0 bridge_dir = Path(args.bridge_dir) session_id = read_active_session_id(bridge_dir) if not session_id: return 0 config = read_hook_config(bridge_dir) ap_server_url = config.get("ap_server_url") if not isinstance(ap_server_url, str) or not ap_server_url: return 0 headers = _headers_from_config(config) tool_name = payload.get("tool_name") if not isinstance(tool_name, str) or not tool_name: return 0 body: dict[str, object] = { "tool_name": tool_name, # Stable re-attach id so a severed long-poll re-parks the SAME # elicitation (mirrors the claude permission hook). "_omnigent_elicitation_id": f"elicit_kimi_{secrets.token_hex(16)}", } tool_input = payload.get("tool_input") if isinstance(tool_input, dict): body["tool_input"] = tool_input url = ( f"{ap_server_url.rstrip('/')}/v1/sessions/" f"{_url_component(session_id)}/hooks/permission-request" ) verdict = _request_web_approval(url, headers, body) if verdict is None: # No web verdict: leave kimi's own TUI prompt for manual approval. return 0 key = APPROVE_KEY if verdict == "allow" else DENY_KEY try: inject_approval_keystroke(bridge_dir, key=key, timeout_s=_SURFACE_TIMEOUT_S) except RuntimeError as exc: print( f"omnigent kimi permission-request hook: keystroke inject failed: {exc}", file=sys.stderr, ) return 0 def _request_web_approval( url: str, headers: dict[str, str], body: dict[str, object] ) -> str | None: """POST the approval card and long-poll for the web verdict. :returns: ``"allow"`` / ``"deny"``, or ``None`` on timeout (server returns an empty 200), transport failure, or an unparseable verdict — all of which fall back to kimi's own TUI prompt. """ timeout = httpx.Timeout(_PERMISSION_REQUEST_TIMEOUT_S, connect=_SURFACE_TIMEOUT_S) try: with httpx.Client(headers=headers, timeout=timeout) as client: resp = client.post(url, json=body) resp.raise_for_status() except httpx.HTTPError as exc: print( f"omnigent kimi permission-request hook: approval request failed: {exc}", file=sys.stderr, ) return None if not resp.content: return None try: data = resp.json() except json.JSONDecodeError: return None return _verdict_from_response(data) def _verdict_from_response(data: object) -> str | None: """Extract ``"allow"`` / ``"deny"`` from the PermissionRequest hook response. The endpoint returns Claude's PermissionRequest contract (``hookSpecificOutput.decision.behavior``), with ``permissionDecision`` as a fallback shape. Any persistent-allow variant (``allow_*``) maps to allow. """ if not isinstance(data, dict): return None hook_output = data.get("hookSpecificOutput") if not isinstance(hook_output, dict): return None decision = hook_output.get("decision") behavior = decision.get("behavior") if isinstance(decision, dict) else None raw = behavior if isinstance(behavior, str) else hook_output.get("permissionDecision") if not isinstance(raw, str): return None low = raw.lower() if low.startswith("allow") or low in ("approve", "approved", "accept"): return "allow" if low in ("deny", "reject", "rejected", "block"): return "deny" return None def _parse_bridge_dir_args(argv: list[str], prog: str) -> argparse.Namespace: """Parse the shared ``--bridge-dir`` argument for a hook subcommand.""" parser = argparse.ArgumentParser(prog=f"omnigent.kimi_native_hook {prog}") parser.add_argument("--bridge-dir", required=True) return parser.parse_args(argv) def main(argv: list[str] | None = None) -> int: """Dispatch a kimi hook subcommand. :param argv: Process argv tail (defaults to ``sys.argv[1:]``). :returns: Process exit code. """ args = list(sys.argv[1:] if argv is None else argv) if not args: print("usage: kimi_native_hook {evaluate-policy|permission-request} ...", file=sys.stderr) return 2 subcommand, rest = args[0], args[1:] if subcommand == "evaluate-policy": return _main_evaluate_policy(rest) if subcommand == "permission-request": return _main_permission_request(rest) print(f"omnigent kimi hook: unknown subcommand {subcommand!r}", file=sys.stderr) return 2 if __name__ == "__main__": raise SystemExit(main())