611 lines
28 KiB
Python
611 lines
28 KiB
Python
"""Shared conversion between native-harness hooks and Omnigent policy events.
|
|
|
|
Both Claude Code and Codex expose a command-hook system whose
|
|
``PreToolUse`` / ``PostToolUse`` payloads use the same field names
|
|
(``hook_event_name``, ``tool_name``, ``tool_input``, ``tool_output``)
|
|
and whose ``UserPromptSubmit`` payload carries the user prompt under
|
|
``prompt``. This module owns the harness-neutral translation between
|
|
that hook shape and the server's proto-compatible ``EvaluationRequest``
|
|
/ ``EvaluationResponse`` schema served by
|
|
``POST /v1/sessions/{id}/policies/evaluate``, so the per-harness hook
|
|
entrypoints (:mod:`omnigent.claude_native_hook`,
|
|
:mod:`omnigent.codex_native_hook`) share one implementation.
|
|
|
|
The output contract differs by hook event: ``PreToolUse`` enforces via
|
|
``hookSpecificOutput.permissionDecision``, while ``UserPromptSubmit``
|
|
enforces via the top-level ``decision`` / ``reason`` fields (both
|
|
harnesses parse ``decision: "block"`` to drop the prompt before the
|
|
model sees it).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import secrets
|
|
import shlex
|
|
import sys
|
|
import time
|
|
from collections.abc import Callable
|
|
|
|
import httpx
|
|
|
|
# How long to keep retrying transient 5xx / connect errors on the
|
|
# policy evaluate POST before failing closed. Keeps the pre-execution
|
|
# gate from blocking long on a sick server while still absorbing brief
|
|
# DB hiccups on a hosted deployment.
|
|
_EVALUATE_POLICY_RETRY_BUDGET_S = 30.0
|
|
_EVALUATE_POLICY_RETRY_INITIAL_BACKOFF_S = 1.0
|
|
_EVALUATE_POLICY_RETRY_MAX_BACKOFF_S = 10.0
|
|
# Fast connect budget so an unreachable server fails into the retry
|
|
# loop quickly rather than blocking on the day-long read timeout.
|
|
_EVALUATE_POLICY_CONNECT_TIMEOUT_S = 5.0
|
|
|
|
# Hook event names that gate tool execution and therefore carry policy
|
|
# meaning. ``PreToolUse`` fires before the tool runs (can block);
|
|
# ``PostToolUse`` fires after (observational — can only warn).
|
|
_PRE_TOOL_USE = "PreToolUse"
|
|
_POST_TOOL_USE = "PostToolUse"
|
|
# ``UserPromptSubmit`` fires when a new user prompt reaches the harness —
|
|
# for native sessions this is the request-phase gate (the server-level
|
|
# ``_evaluate_input_policy`` is bypassed for native message events, so
|
|
# this hook is the sole REQUEST gate and covers both web-UI-injected and
|
|
# direct-terminal prompts). It can block the prompt before the model runs.
|
|
_USER_PROMPT_SUBMIT = "UserPromptSubmit"
|
|
|
|
# Reason surfaced when a tool call is denied because its policy verdict
|
|
# could not be obtained (server unreachable / non-2xx / empty or malformed
|
|
# body). Mirrors the runner-side fail-closed default in
|
|
# ``omnigent.runner.app._evaluate_policy_via_omnigent``.
|
|
_EVAL_UNAVAILABLE_REASON = (
|
|
"Omnigent policy evaluation unavailable (could not reach or authenticate to the "
|
|
"Omnigent server); failing closed for this tool call."
|
|
)
|
|
_EVAL_UNAVAILABLE_REQUEST_REASON = (
|
|
"Omnigent policy evaluation unavailable (could not reach or authenticate to the "
|
|
"Omnigent server); failing closed for this request."
|
|
)
|
|
|
|
|
|
# Env var carrying the one-shot auth + workspace-routing headers from the
|
|
# executor (which writes the hook wrapper) to the hook subprocess. The hook
|
|
# is import-free of the runner, so the headers are passed in rather than
|
|
# resolved in-process — the same reason ``ap_auth_headers`` is baked for the
|
|
# claude/codex/kimi hooks.
|
|
_AUTH_HEADERS_ENV = "_OMNIGENT_AUTH_HEADERS"
|
|
|
|
|
|
def policy_hook_request_headers() -> dict[str, str]:
|
|
"""Build the headers for a policy-hook subprocess's POST to the server.
|
|
|
|
Always carries ``Content-Type``, and merges the one-shot auth +
|
|
workspace-routing headers the executor baked into :data:`_AUTH_HEADERS_ENV`
|
|
at launch (see :func:`policy_hook_wrapper_script`). Without them the POST
|
|
is unauthenticated and unrouted — it 401s on an authenticated server and
|
|
misroutes to the account on a unified-account workspace. Missing or
|
|
malformed env → just ``Content-Type`` (a local unauthenticated server
|
|
needs no auth).
|
|
|
|
:returns: Request headers for ``post_evaluate_with_retry``.
|
|
"""
|
|
headers = {"Content-Type": "application/json"}
|
|
raw = os.environ.get(_AUTH_HEADERS_ENV, "")
|
|
if raw:
|
|
try:
|
|
extra = json.loads(raw)
|
|
except (json.JSONDecodeError, ValueError):
|
|
extra = None
|
|
if isinstance(extra, dict):
|
|
headers.update({str(k): str(v) for k, v in extra.items()})
|
|
return headers
|
|
|
|
|
|
def policy_hook_wrapper_script(server_url: str, session_id: str, hook_script_path: str) -> str:
|
|
"""Build the ``/bin/sh`` wrapper a native policy hook is launched as.
|
|
|
|
Resolves a one-shot Omnigent-server token and bakes the auth +
|
|
workspace-routing headers (via
|
|
:func:`omnigent.cli_auth.databricks_request_headers`) into
|
|
:data:`_AUTH_HEADERS_ENV`, so the hook's POST authenticates and routes to
|
|
the workspace. The token is a secret, so callers MUST write the returned
|
|
wrapper ``0o700`` (owner-only) — it is never world-readable.
|
|
|
|
:param server_url: Omnigent server base URL the hook posts to.
|
|
:param session_id: Session / conversation id for policy evaluation.
|
|
:param hook_script_path: Absolute path to the hook's Python entrypoint.
|
|
:returns: Shell-script text for the wrapper (write it ``0o700``).
|
|
"""
|
|
from omnigent.cli_auth import databricks_request_headers
|
|
from omnigent.runner._entry import _make_auth_token_factory
|
|
|
|
factory = _make_auth_token_factory(server_url=server_url)
|
|
token = factory() if factory is not None else None
|
|
auth_headers = databricks_request_headers(server_url, bearer_token=token)
|
|
return (
|
|
"#!/bin/sh\n"
|
|
f"export _OMNIGENT_SERVER_URL={shlex.quote(server_url)}\n"
|
|
f"export _OMNIGENT_SESSION_ID={shlex.quote(session_id)}\n"
|
|
f"export {_AUTH_HEADERS_ENV}={shlex.quote(json.dumps(auth_headers))}\n"
|
|
f"exec {shlex.quote(sys.executable)} {shlex.quote(hook_script_path)}\n"
|
|
)
|
|
|
|
|
|
class PolicyHookReauth:
|
|
"""Callable that re-mints the Omnigent bearer for a policy hook subprocess.
|
|
|
|
The baked one-shot token dies with the ~1h Databricks OAuth lifetime; on a
|
|
lapsed-token signal (401 or Apps ``302→/oidc/``) ``post_evaluate_with_retry``
|
|
calls this once to mint a fresh bearer through the same factory the
|
|
refresh-capable runtime auth uses, keeping the other headers (e.g.
|
|
``X-Databricks-Org-Id``) so routing survives.
|
|
|
|
The ``failure_reason`` attribute is set to a short diagnostic string when
|
|
the re-mint fails so callers can surface it in the fail-closed message shown
|
|
to the user — stderr from hook subprocesses is discarded by the harness, so
|
|
this is the only channel that reaches the UI.
|
|
"""
|
|
|
|
failure_reason: str | None
|
|
|
|
def __init__(self, server_url: str, headers: dict[str, str]) -> None:
|
|
self._server_url = server_url
|
|
self._headers = headers
|
|
self.failure_reason = None
|
|
|
|
def __call__(self) -> dict[str, str] | None:
|
|
# Lazy import: paid only on the rare re-auth path, off the hot path.
|
|
try:
|
|
from omnigent.runner._entry import _make_auth_token_factory
|
|
except Exception as exc: # noqa: BLE001 — best-effort; fail closed if unavailable
|
|
self.failure_reason = f"auth factory unavailable: {exc}"
|
|
return None
|
|
factory = _make_auth_token_factory(self._server_url)
|
|
if factory is None:
|
|
self.failure_reason = (
|
|
"no credential resolved "
|
|
f"(no stored token and no Databricks SDK auth for {self._server_url!r})"
|
|
)
|
|
return None
|
|
try:
|
|
token = factory()
|
|
except Exception as exc: # noqa: BLE001 — transient mint failure; fail closed
|
|
self.failure_reason = f"token mint failed: {exc}"
|
|
return None
|
|
if not token:
|
|
self.failure_reason = "auth factory returned empty token"
|
|
return None
|
|
self.failure_reason = None
|
|
return {**self._headers, "Authorization": f"Bearer {token}"}
|
|
|
|
|
|
def policy_hook_reauth(server_url: str, headers: dict[str, str]) -> PolicyHookReauth:
|
|
"""Build a :class:`PolicyHookReauth` callable for *server_url*.
|
|
|
|
:param server_url: Omnigent server base URL the hook POSTs to.
|
|
:param headers: Current (lapsed) headers; the fresh bearer is merged over
|
|
a copy so routing headers survive.
|
|
:returns: A :class:`PolicyHookReauth` instance. Call it to attempt a
|
|
re-mint; check ``.failure_reason`` afterwards when it returns ``None``.
|
|
"""
|
|
return PolicyHookReauth(server_url, headers)
|
|
|
|
|
|
def _is_login_redirect_or_unauthorized(response: httpx.Response) -> bool:
|
|
"""
|
|
Return ``True`` when a response means "the bearer is no good — re-auth".
|
|
|
|
Mirrors :func:`omnigent.runner._entry._is_login_redirect_or_unauthorized`,
|
|
duplicated here so the dependency-light hook need not import the runner
|
|
package. The Databricks Apps front door bounces an *expired* bearer with a
|
|
``302`` to the OAuth login flow (``/oidc/`` or ``/.auth/``) — **not** a
|
|
``401`` — so a hook that only treats ``401`` as auth failure silently fails
|
|
closed once the one-shot ``ap_auth_headers`` token (snapshotted at launch by
|
|
``build_hook_settings``) lapses with the ~1h Databricks OAuth lifetime.
|
|
Treat the 401, 403 "Invalid Token", and the OAuth-login redirect as
|
|
re-auth signals.
|
|
|
|
Unrelated 3xx (an application-level redirect to another resource) return
|
|
``False`` so the caller does not waste a token round-trip on every redirect.
|
|
|
|
Note: Databricks Apps returns 403 (not 401) with body "Invalid Token"
|
|
when a bearer has expired, in addition to the 302→``/oidc/`` bounce. A
|
|
caller that only watches for 401 and the redirect silently fails closed
|
|
on sessions older than ~1h.
|
|
|
|
:param response: The hook's POST response to classify.
|
|
:returns: ``True`` when the caller should re-mint a token and retry.
|
|
"""
|
|
if response.status_code in (401, 403):
|
|
return True
|
|
if not response.is_redirect:
|
|
return False
|
|
location = response.headers.get("location", "")
|
|
return "/oidc/" in location or "/.auth/" in location
|
|
|
|
|
|
def hook_payload_to_evaluation_request(
|
|
hook_event: str,
|
|
payload: dict[str, object],
|
|
) -> dict[str, object] | None:
|
|
"""
|
|
Convert a native-harness tool-hook payload into a proto ``EvaluationRequest``.
|
|
|
|
Maps ``PreToolUse`` to a ``PHASE_TOOL_CALL`` event, ``PostToolUse``
|
|
to a ``PHASE_TOOL_RESULT`` event, and ``UserPromptSubmit`` to a
|
|
``PHASE_REQUEST`` event (the prompt text from the payload's
|
|
``prompt`` field becomes the request content). Omnigent MCP tools
|
|
(``mcp__omnigent__*``) are skipped because they are already
|
|
policy-checked by the relay path (``ProxyMcpManager`` → Omnigent
|
|
``/mcp`` endpoint → ``_evaluate_tool_call_policy``); evaluating
|
|
them here would double-count. Connector-native MCP tools
|
|
(for example ``mcp__github__*``) still need this pre-call gate.
|
|
|
|
:param hook_event: Hook event name from the payload's
|
|
``hook_event_name`` field, e.g. ``"PreToolUse"``,
|
|
``"PostToolUse"``, or ``"UserPromptSubmit"``.
|
|
:param payload: Raw hook JSON from the harness, e.g.
|
|
``{"hook_event_name": "PreToolUse", "tool_name": "Bash",
|
|
"tool_input": {"command": "rm -rf /"}}``.
|
|
:returns: An ``EvaluationRequest`` dict suitable for POSTing to
|
|
``/policies/evaluate``, or ``None`` when the event is not
|
|
policy-relevant (unknown event or an ``mcp__omnigent__*`` tool).
|
|
"""
|
|
if hook_event == _USER_PROMPT_SUBMIT:
|
|
# Request-phase gate for native sessions. The server reads REQUEST
|
|
# content from ``data.text`` (see ``_build_evaluation_context``).
|
|
prompt = payload.get("prompt", "")
|
|
return {
|
|
"event": {
|
|
"type": "PHASE_REQUEST",
|
|
"target": "",
|
|
"data": {
|
|
"text": prompt if isinstance(prompt, str) else json.dumps(prompt),
|
|
},
|
|
"context": {},
|
|
},
|
|
}
|
|
tool_name = payload.get("tool_name", "")
|
|
# Omnigent MCP tools are already policy-checked by the relay path
|
|
# (ProxyMcpManager → Omnigent /mcp endpoint → _evaluate_tool_call_policy).
|
|
# Skip only those here to avoid double evaluation; connector-native MCP
|
|
# tools such as mcp__github__* must still go through this hook.
|
|
if isinstance(tool_name, str) and tool_name.startswith("mcp__omnigent__"):
|
|
return None
|
|
tool_input = payload.get("tool_input") or {}
|
|
if hook_event == _PRE_TOOL_USE:
|
|
return {
|
|
"event": {
|
|
"type": "PHASE_TOOL_CALL",
|
|
"target": "",
|
|
"data": {
|
|
"name": tool_name,
|
|
"arguments": tool_input,
|
|
},
|
|
"context": {},
|
|
},
|
|
}
|
|
if hook_event == _POST_TOOL_USE:
|
|
tool_output = payload.get("tool_output", "")
|
|
return {
|
|
"event": {
|
|
"type": "PHASE_TOOL_RESULT",
|
|
"target": "",
|
|
"data": {
|
|
"result": tool_output,
|
|
},
|
|
"context": {},
|
|
"request_data": {
|
|
"name": tool_name,
|
|
"arguments": tool_input,
|
|
},
|
|
},
|
|
}
|
|
return None
|
|
|
|
|
|
def evaluation_response_to_hook_output(
|
|
hook_event: str,
|
|
eval_response: dict[str, object],
|
|
) -> dict[str, object] | None:
|
|
"""
|
|
Convert an ``EvaluationResponse`` into native-harness hook output JSON.
|
|
|
|
For ``PreToolUse`` the policy layer only *enforces* — it emits a
|
|
``hookSpecificOutput.permissionDecision`` solely for verdicts that
|
|
constrain the tool: ``POLICY_ACTION_DENY`` → ``"deny"`` (with
|
|
``permissionDecisionReason``). ``POLICY_ACTION_ASK`` is resolved
|
|
server-side now (URL-based elicitation: ``POST /policies/evaluate``
|
|
holds the gate and returns a hard ALLOW/DENY), so the hook should
|
|
never see ASK; if it does, it fails closed with ``"deny"`` rather
|
|
than the old ``"defer"`` — ``defer`` handed control back to the
|
|
harness's ``permission_mode``, which ``acceptEdits`` /
|
|
``bypassPermissions`` would auto-approve, bypassing the human.
|
|
``POLICY_ACTION_ALLOW`` — which is the engine's default verdict when
|
|
no policy matches a tool call, not just an explicit author allow —
|
|
returns ``None`` ("no opinion") so the harness's *own* permission
|
|
system still runs. Emitting ``"allow"`` here would auto-approve the
|
|
tool and suppress the harness's native permission prompt (and, for
|
|
Claude Code, the ``PermissionRequest`` hook that routes that prompt
|
|
to the web UI), collapsing two independent gates — the deployment's
|
|
policy gate and the user's own consent gate — into one. The policy
|
|
layer may block (DENY) or demand approval (ASK); it must not silence
|
|
the user's consent. For ``PostToolUse`` a ``DENY`` is surfaced as
|
|
``additionalContext`` because the tool result is already committed
|
|
— PostToolUse hooks cannot block.
|
|
|
|
For ``UserPromptSubmit`` the output uses the top-level ``decision`` /
|
|
``reason`` contract (not ``permissionDecision``): ``DENY`` → ``{"decision":
|
|
"block", "reason": ...}``, which drops the prompt before the model sees
|
|
it. ASK is resolved server-side (``_hold_native_ask_gate`` collapses it
|
|
to a hard ALLOW/DENY before the response reaches the hook), so the hook
|
|
should never see ASK; if it somehow does, it fails closed by blocking.
|
|
ALLOW (and the engine's no-match default) returns ``None`` so the prompt
|
|
proceeds. Unlike ``PreToolUse``, there is no separate user-consent gate
|
|
on a prompt, so ALLOW need not preserve one.
|
|
|
|
Both Claude Code and Codex consume these exact output shapes, so the
|
|
``hookEventName`` echoed back is the harness-supplied ``hook_event``.
|
|
|
|
:param hook_event: Hook event name, e.g. ``"PreToolUse"``,
|
|
``"PostToolUse"``, or ``"UserPromptSubmit"``.
|
|
:param eval_response: Parsed ``EvaluationResponse`` from AP, e.g.
|
|
``{"result": "POLICY_ACTION_DENY", "reason": "blocked by policy"}``.
|
|
:returns: Hook output dict for the harness to read on stdout, or
|
|
``None`` when there is no verdict to express (allow with no
|
|
rewrite on PostToolUse, or an unknown action).
|
|
"""
|
|
action = eval_response.get("result", "POLICY_ACTION_UNSPECIFIED")
|
|
reason = eval_response.get("reason")
|
|
|
|
if hook_event == _USER_PROMPT_SUBMIT:
|
|
# DENY blocks the prompt; a stray ASK fails closed (also block) since
|
|
# ASK is meant to be resolved server-side before reaching the hook.
|
|
# ALLOW / no-match → None so the prompt proceeds. A non-empty reason
|
|
# is required for the block to take effect (both harnesses drop a
|
|
# block with an empty reason), so default one in.
|
|
if action in ("POLICY_ACTION_DENY", "POLICY_ACTION_ASK"):
|
|
return {
|
|
"decision": "block",
|
|
"reason": reason or "Denied by policy",
|
|
}
|
|
return None
|
|
|
|
if hook_event == _PRE_TOOL_USE:
|
|
# ALLOW (the engine default when no policy matches) is omitted → None,
|
|
# so the harness's own permission prompt still fires; see docstring.
|
|
decision_map = {
|
|
"POLICY_ACTION_DENY": "deny",
|
|
# ASK is resolved server-side now (URL-based elicitation:
|
|
# POST /policies/evaluate holds the gate and returns a hard
|
|
# ALLOW/DENY), so the hook should never see ASK here. If it
|
|
# somehow does, fail closed with ``deny`` rather than the old
|
|
# ``defer`` — ``defer`` returns control to the harness's
|
|
# permission_mode, which acceptEdits / bypassPermissions would
|
|
# auto-approve, re-opening the very bypass this closes.
|
|
"POLICY_ACTION_ASK": "deny",
|
|
}
|
|
decision = decision_map.get(str(action))
|
|
if decision is None:
|
|
return None
|
|
output: dict[str, object] = {
|
|
"hookEventName": _PRE_TOOL_USE,
|
|
"permissionDecision": decision,
|
|
}
|
|
if decision == "deny" and reason:
|
|
output["permissionDecisionReason"] = reason
|
|
return {"hookSpecificOutput": output}
|
|
|
|
if hook_event == _POST_TOOL_USE:
|
|
if action == "POLICY_ACTION_DENY" and reason:
|
|
return {
|
|
"hookSpecificOutput": {
|
|
"hookEventName": _POST_TOOL_USE,
|
|
"additionalContext": f"[Policy violation] {reason}",
|
|
},
|
|
}
|
|
return None
|
|
|
|
return None
|
|
|
|
|
|
def fail_closed_hook_output(
|
|
hook_event: str, detail: str | None = None
|
|
) -> dict[str, object] | None:
|
|
"""
|
|
Build the fail-closed hook output for an unobtainable policy verdict.
|
|
|
|
Called by the per-harness hooks when the ``/policies/evaluate``
|
|
round-trip cannot produce a usable verdict for an *already-governed*
|
|
session — the server is unreachable, returns a non-2xx status, or
|
|
returns an empty / malformed body. Without this the hooks emitted "no
|
|
opinion" on those paths, silently letting the gated tool run: for
|
|
native harnesses this hook is the sole enforcement point (it gates
|
|
Bash / Write / Edit / the native Skill tool / connector-native
|
|
``mcp__*`` tools), so a transient outage disabled all DENY/ASK
|
|
enforcement.
|
|
|
|
The default is phase-aware, matching
|
|
:data:`omnigent.policies.types.FAIL_CLOSED_PHASES` (the runner-side
|
|
precedent from PR #163) — but expressed in hook-event terms so the
|
|
lightweight hook subprocess need not import the policy package:
|
|
|
|
- ``PreToolUse`` (``PHASE_TOOL_CALL``) fails CLOSED → ``deny``. This is
|
|
the authoritative pre-execution gate; an unevaluable policy must not
|
|
let the call through.
|
|
- ``UserPromptSubmit`` (``PHASE_REQUEST``) fails CLOSED →
|
|
``{"decision": "block", ...}``. This is the sole pre-turn enforcement
|
|
point for native sessions; a server hiccup must not let an over-budget
|
|
or otherwise-blocked request proceed.
|
|
- ``PostToolUse`` (``PHASE_TOOL_RESULT``) fails OPEN → ``None``. By the
|
|
result phase the tool has already executed, so denying would only block
|
|
an already-incurred side effect.
|
|
|
|
:param hook_event: Hook event name, e.g. ``"PreToolUse"``.
|
|
:param detail: Optional short diagnostic string appended to the reason
|
|
shown in the UI, e.g. a reauth failure message from
|
|
:attr:`PolicyHookReauth.failure_reason`. Omit when no detail is
|
|
available.
|
|
:returns: A ``permissionDecision: "deny"`` hook output for
|
|
``PreToolUse``; a ``decision: "block"`` output for
|
|
``UserPromptSubmit``; ``None`` for every other event (fail open).
|
|
"""
|
|
tool_reason = (
|
|
f"{_EVAL_UNAVAILABLE_REASON} Detail: {detail}" if detail else _EVAL_UNAVAILABLE_REASON
|
|
)
|
|
request_reason = (
|
|
f"{_EVAL_UNAVAILABLE_REQUEST_REASON} Detail: {detail}"
|
|
if detail
|
|
else _EVAL_UNAVAILABLE_REQUEST_REASON
|
|
)
|
|
if hook_event == _PRE_TOOL_USE:
|
|
return {
|
|
"hookSpecificOutput": {
|
|
"hookEventName": _PRE_TOOL_USE,
|
|
"permissionDecision": "deny",
|
|
"permissionDecisionReason": tool_reason,
|
|
},
|
|
}
|
|
if hook_event == _USER_PROMPT_SUBMIT:
|
|
return {
|
|
"decision": "block",
|
|
"reason": request_reason,
|
|
}
|
|
return None
|
|
|
|
|
|
def post_evaluate_with_retry(
|
|
url: str,
|
|
headers: dict[str, str],
|
|
eval_request: dict[str, object],
|
|
read_timeout: float,
|
|
hook_label: str,
|
|
reauth: Callable[[], dict[str, str] | None] | None = None,
|
|
) -> tuple[httpx.Response, None] | tuple[None, str]:
|
|
"""
|
|
POST to the Omnigent policy evaluate endpoint, retrying on transient errors.
|
|
|
|
Retries on 5xx HTTP responses and connection-level errors
|
|
(:class:`httpx.ConnectError`, :class:`httpx.ConnectTimeout`) within
|
|
:data:`_EVALUATE_POLICY_RETRY_BUDGET_S`. Returns the successful response,
|
|
or ``None`` if the budget is exhausted or a non-retryable error occurs.
|
|
|
|
A stable ``_omnigent_elicitation_id`` is minted once and stamped on
|
|
every attempt. When the server parks an ASK gate and the connection
|
|
drops (5xx or :class:`httpx.ConnectError`), the retry re-POSTs the
|
|
same id so the server re-attaches to the existing elicitation rather
|
|
than minting a new one — mirroring the ``_post_hook_with_reattach``
|
|
idiom used by the ``PermissionRequest`` hook. This prevents a
|
|
second approval card from appearing when the first was already
|
|
published before the error.
|
|
|
|
4xx responses are final — a bad request won't succeed on retry. Other
|
|
mid-stream errors (e.g. :class:`httpx.ReadTimeout`) are also not retried:
|
|
a read timeout fires *after* the server received the request and may
|
|
mean the long-polling ASK gate was severed mid-wait; retrying with the
|
|
same id will re-park the existing elicitation (no duplicate card), but
|
|
the caller's fail-closed path is equivalent and simpler. The caller is
|
|
responsible for fail-closed handling on ``None``.
|
|
|
|
:param url: Absolute URL of the evaluate endpoint.
|
|
:param headers: Auth headers for the Omnigent server.
|
|
:param eval_request: ``EvaluationRequest`` JSON body to POST.
|
|
:param read_timeout: Per-attempt read timeout in seconds. Should be
|
|
large (e.g. one day) to accommodate long-polling ASK gates.
|
|
:param hook_label: Diagnostic label used in stderr messages,
|
|
e.g. ``"evaluate-policy hook"`` or ``"codex evaluate-policy hook"``.
|
|
:param reauth: Optional callable that re-mints fresh auth headers when
|
|
the server bounces the request to its OAuth login flow (the Apps
|
|
front door 302→``/oidc/``) or returns ``401`` — i.e. the one-shot
|
|
``ap_auth_headers`` token lapsed. Called at most once; returning new
|
|
headers triggers an immediate retry with them, mirroring the runner's
|
|
refresh-capable :class:`~omnigent.runner._entry._RunnerDatabricksAuth`.
|
|
``None`` (the default) keeps the legacy behavior for callers that have
|
|
no token source. Returning ``None`` from it falls through to the
|
|
normal failure handling (the caller fails closed).
|
|
:returns: ``(response, error)`` — on success, ``(response, None)``; on
|
|
failure, ``(None, short_error_string)`` describing the last error so
|
|
callers can surface it in the deny/block reason shown to the user.
|
|
"""
|
|
# Mint one stable id for the whole retry sequence. Each retry re-sends
|
|
# it so the server can re-park the SAME elicitation rather than opening
|
|
# a second approval card. The ``elicit_evaluate_`` namespace is validated
|
|
# server-side by ``_EVALUATE_HOOK_ELICITATION_ID_RE``.
|
|
elicitation_id = f"elicit_evaluate_{secrets.token_hex(16)}"
|
|
request_body = {**eval_request, "_omnigent_elicitation_id": elicitation_id}
|
|
deadline = time.monotonic() + _EVALUATE_POLICY_RETRY_BUDGET_S
|
|
backoff_s = _EVALUATE_POLICY_RETRY_INITIAL_BACKOFF_S
|
|
timeout = httpx.Timeout(read_timeout, connect=_EVALUATE_POLICY_CONNECT_TIMEOUT_S)
|
|
reauthed = False
|
|
last_error: str = "unknown error"
|
|
while True:
|
|
try:
|
|
with httpx.Client(headers=headers, timeout=timeout) as client:
|
|
resp = client.post(url, json=request_body)
|
|
if (
|
|
reauth is not None
|
|
and not reauthed
|
|
and _is_login_redirect_or_unauthorized(resp)
|
|
):
|
|
# The one-shot ``ap_auth_headers`` token lapsed (~1h
|
|
# Databricks OAuth lifetime): the Apps front door bounces
|
|
# an expired bearer with a 302→/oidc/ (or a 401). Re-mint
|
|
# and retry once with the fresh token instead of failing
|
|
# closed — exactly as ``_RunnerDatabricksAuth`` does for the
|
|
# runner's own callbacks. Without this, every tool call on a
|
|
# session older than the token lifetime fails CLOSED while
|
|
# chat (refresh-capable) keeps working.
|
|
refreshed = reauth()
|
|
if refreshed:
|
|
headers = refreshed
|
|
reauthed = True
|
|
print(
|
|
f"omnigent {hook_label}: Omnigent auth expired "
|
|
"(login redirect/401); re-minted token and retrying",
|
|
file=sys.stderr,
|
|
)
|
|
continue
|
|
resp.raise_for_status()
|
|
return resp, None
|
|
except httpx.HTTPStatusError as exc:
|
|
status = exc.response.status_code
|
|
body_preview = exc.response.text[:200] if exc.response.content else ""
|
|
last_error = f"server returned {status}" + (
|
|
f": {body_preview}" if body_preview else ""
|
|
)
|
|
if status < 500:
|
|
print(
|
|
f"omnigent {hook_label}: Omnigent returned {status}"
|
|
+ (f": {body_preview}" if body_preview else ""),
|
|
file=sys.stderr,
|
|
)
|
|
return None, last_error
|
|
print(
|
|
f"omnigent {hook_label}: Omnigent returned {status}; retrying",
|
|
file=sys.stderr,
|
|
)
|
|
except (httpx.ConnectError, httpx.ConnectTimeout) as exc:
|
|
last_error = f"connection error: {exc}"
|
|
print(
|
|
f"omnigent {hook_label}: Omnigent request failed; retrying: {exc}",
|
|
file=sys.stderr,
|
|
)
|
|
except httpx.HTTPError as exc:
|
|
# Other HTTP errors (ReadTimeout while a long ASK poll is in flight,
|
|
# etc.) are not retried — retrying a severed ASK would open a new
|
|
# elicitation and prompt the human twice.
|
|
last_error = f"request error: {exc}"
|
|
print(
|
|
f"omnigent {hook_label}: Omnigent request failed: {exc}",
|
|
file=sys.stderr,
|
|
)
|
|
return None, last_error
|
|
if time.monotonic() + backoff_s >= deadline:
|
|
print(
|
|
f"omnigent {hook_label}: retry budget exhausted",
|
|
file=sys.stderr,
|
|
)
|
|
return None, f"retry budget exhausted (last error: {last_error})"
|
|
# Two-step backoff; not worth a retry library in this dependency-light hook.
|
|
time.sleep(backoff_s)
|
|
backoff_s = min(backoff_s * 2, _EVALUATE_POLICY_RETRY_MAX_BACKOFF_S)
|