Current section
Files
Jump to
Current section
Files
lib/codex/app_server/approvals.ex
defmodule Codex.AppServer.Approvals do
@moduledoc false
require Logger
alias Codex.Approvals.Hook
alias Codex.AppServer.ApprovalDecision
alias Codex.AppServer.Connection
alias Codex.Thread
@spec maybe_auto_respond(
Connection.connection(),
Thread.t(),
Connection.request_id(),
String.t(),
map()
) ::
:ok | :ignore
def maybe_auto_respond(conn, %Thread{} = thread, id, method, params)
when is_pid(conn) and (is_integer(id) or is_binary(id)) and is_binary(method) and
is_map(params) do
case approval_handler(thread) do
nil ->
:ignore
handler ->
do_auto_respond(conn, handler, thread, id, method, params)
end
end
def maybe_auto_respond(_conn, _thread, _id, _method, _params), do: :ignore
defp approval_handler(%Thread{} = thread) do
thread.thread_opts.approval_hook || thread.thread_opts.approval_policy
end
defp do_auto_respond(conn, handler, %Thread{} = thread, id, method, params) do
with {:ok, callback, event} <- approval_event(method, params),
:ok <- ensure_matches_thread(thread, event),
{:ok, decision} <- review(handler, callback, event, thread),
:ok <- Connection.respond(conn, id, %{decision: ApprovalDecision.from_hook(decision)}) do
:ok
else
:ignore ->
:ignore
{:error, reason} ->
Logger.debug("Failed to auto-respond to app-server approval request: #{inspect(reason)}")
_ =
Connection.respond(conn, id, %{
decision: ApprovalDecision.from_hook({:deny, "approval error: #{inspect(reason)}"})
})
:ok
end
end
defp approval_event("item/commandExecution/requestApproval", %{} = params) do
{:ok, :review_command,
%{
type: :command_execution_approval,
thread_id: Map.get(params, "threadId") || Map.get(params, "thread_id") || "",
turn_id: Map.get(params, "turnId") || Map.get(params, "turn_id") || "",
item_id: Map.get(params, "itemId") || Map.get(params, "item_id") || "",
reason: Map.get(params, "reason"),
proposed_execpolicy_amendment:
Map.get(params, "proposedExecpolicyAmendment") ||
Map.get(params, "proposed_execpolicy_amendment")
}}
end
defp approval_event("item/fileChange/requestApproval", %{} = params) do
{:ok, :review_file,
%{
type: :file_change_approval,
thread_id: Map.get(params, "threadId") || Map.get(params, "thread_id") || "",
turn_id: Map.get(params, "turnId") || Map.get(params, "turn_id") || "",
item_id: Map.get(params, "itemId") || Map.get(params, "item_id") || "",
reason: Map.get(params, "reason"),
grant_root: Map.get(params, "grantRoot") || Map.get(params, "grant_root")
}}
end
defp approval_event(_method, _params), do: :ignore
defp ensure_matches_thread(%Thread{thread_id: nil}, _event), do: :ok
defp ensure_matches_thread(%Thread{thread_id: thread_id}, %{thread_id: thread_id})
when is_binary(thread_id),
do: :ok
defp ensure_matches_thread(%Thread{thread_id: thread_id}, %{thread_id: other})
when is_binary(thread_id) and is_binary(other) and other != "" and thread_id != "" do
{:error, {:thread_id_mismatch, thread_id, other}}
end
defp ensure_matches_thread(_thread, _event), do: :ok
defp review(handler, callback, event, %Thread{} = thread) do
timeout = thread.thread_opts.approval_timeout_ms || 30_000
base_context = %{
thread: thread,
metadata: thread.thread_opts.metadata || %{},
transport: :app_server
}
context = prepare_context(handler, event, base_context)
opts = []
try do
handler
|> invoke_review(callback, event, context, opts)
|> then(&resolve_decision(handler, &1, timeout))
rescue
exception ->
{:error, {:hook_crash, exception}}
end
end
defp prepare_context(handler, event, context) do
if function_exported?(handler, :prepare, 2) do
case handler.prepare(event, context) do
{:ok, prepared} when is_map(prepared) -> prepared
_ -> context
end
else
context
end
end
defp invoke_review(handler, :review_command, event, context, opts) do
if function_exported?(handler, :review_command, 3) do
handler.review_command(event, context, opts)
else
Hook.default_review(event, context, opts)
end
end
defp invoke_review(handler, :review_file, event, context, opts) do
if function_exported?(handler, :review_file, 3) do
handler.review_file(event, context, opts)
else
Hook.default_review(event, context, opts)
end
end
defp resolve_decision(_handler, :allow, _timeout), do: {:ok, :allow}
defp resolve_decision(_handler, {:allow, _opts} = decision, _timeout), do: {:ok, decision}
defp resolve_decision(_handler, {:deny, _reason} = decision, _timeout), do: {:ok, decision}
defp resolve_decision(handler, {:async, ref}, timeout),
do: await_decision(handler, ref, timeout)
defp resolve_decision(handler, {:async, ref, _metadata}, timeout),
do: await_decision(handler, ref, timeout)
defp resolve_decision(_handler, other, _timeout), do: {:error, {:invalid_hook_decision, other}}
defp await_decision(handler, ref, timeout) do
if function_exported?(handler, :await, 2) do
case handler.await(ref, timeout) do
{:ok, decision} -> resolve_decision(handler, decision, timeout)
{:error, :timeout} -> {:ok, {:deny, "approval timeout"}}
{:error, reason} -> {:ok, {:deny, "approval error: #{inspect(reason)}"}}
end
else
{:ok, {:deny, "async approval not supported"}}
end
end
end