Current section
Files
Jump to
Current section
Files
lib/codex/approvals.ex
defmodule Codex.Approvals do
@moduledoc """
Approval helpers invoked by the auto-run pipeline when actions require consent.
Supports both synchronous and asynchronous approval workflows via pluggable hooks.
"""
alias Codex.Approvals.StaticPolicy
alias Codex.Telemetry
@type decision :: :allow | {:deny, String.t()}
@type async_result :: {:async, reference()} | {:async, reference(), metadata :: map()}
@type review_result :: decision() | async_result()
@doc """
Reviews a tool invocation given the configured policy or hook.
## Parameters
- `policy_or_hook` - StaticPolicy struct, hook module, or nil
- `event` - Tool call event (must contain `:tool_name` and `:call_id`)
- `context` - Approval context
- `opts` - Optional keyword list with `:timeout` (default: 30_000ms)
## Returns
- `:allow` - approve the operation
- `{:deny, reason}` - deny with reason
- `{:async, ref}` or `{:async, ref, metadata}` - async approval pending
## Telemetry
Emits the following events:
- `[:codex, :approval, :requested]` - when approval is requested
- `[:codex, :approval, :approved]` - when synchronously approved
- `[:codex, :approval, :denied]` - when denied
- `[:codex, :approval, :timeout]` - when async approval times out
"""
@spec review_tool(term(), map(), map(), keyword()) :: review_result()
def review_tool(policy_or_hook, event, context, opts \\ [])
# Nil policy - allow by default
def review_tool(nil, _event, _context, _opts), do: :allow
# StaticPolicy - backwards compatible
def review_tool(%StaticPolicy{} = policy, event, context, _opts) do
emit_requested_telemetry(event)
started = System.monotonic_time()
result = StaticPolicy.review_tool(policy, event, context)
emit_result_telemetry(result, event, started)
result
end
# Hook module - new behaviour
def review_tool(module, event, context, opts) when is_atom(module) do
emit_requested_telemetry(event)
started = System.monotonic_time()
timeout = Keyword.get(opts, :timeout, 30_000)
# Call prepare if available
context =
if function_exported?(module, :prepare, 2) do
case module.prepare(event, context) do
{:ok, new_context} -> new_context
{:error, _} -> context
end
else
context
end
# Call review_tool
case module.review_tool(event, context, opts) do
:allow = result ->
emit_result_telemetry(result, event, started)
result
{:deny, _reason} = result ->
emit_result_telemetry(result, event, started)
result
{:async, ref} ->
# Handle async - await if the hook supports it
if function_exported?(module, :await, 2) do
# Store test_pid in process dictionary for hook to access
case module.await(ref, timeout) do
{:ok, decision} ->
emit_result_telemetry(decision, event, started)
decision
{:error, :timeout} ->
emit_timeout_telemetry(event, started)
{:deny, "approval timeout"}
{:error, reason} ->
result = {:deny, "approval error: #{inspect(reason)}"}
emit_result_telemetry(result, event, started)
result
end
else
# Hook doesn't support await, return async
{:async, ref}
end
{:async, ref, metadata} ->
if function_exported?(module, :await, 2) do
case module.await(ref, timeout) do
{:ok, decision} ->
emit_result_telemetry(decision, event, started)
decision
{:error, :timeout} ->
emit_timeout_telemetry(event, started)
{:deny, "approval timeout"}
{:error, reason} ->
result = {:deny, "approval error: #{inspect(reason)}"}
emit_result_telemetry(result, event, started)
result
end
else
{:async, ref, metadata}
end
end
end
defp emit_requested_telemetry(event) do
tool_name = get_event_field(event, :tool_name)
call_id = get_event_field(event, :call_id)
Telemetry.emit(
[:codex, :approval, :requested],
%{system_time: System.system_time()},
%{
tool: tool_name,
call_id: call_id,
originator: :sdk
}
)
end
defp emit_result_telemetry(:allow, event, started) do
duration = System.monotonic_time() - started
tool_name = get_event_field(event, :tool_name)
Telemetry.emit(
[:codex, :approval, :approved],
%{duration: duration, system_time: System.system_time()},
%{
tool: tool_name,
originator: :sdk
}
)
end
defp emit_result_telemetry({:deny, reason}, event, started) do
duration = System.monotonic_time() - started
tool_name = get_event_field(event, :tool_name)
Telemetry.emit(
[:codex, :approval, :denied],
%{duration: duration, system_time: System.system_time()},
%{
tool: tool_name,
reason: reason,
originator: :sdk
}
)
end
defp emit_timeout_telemetry(event, started) do
duration = System.monotonic_time() - started
tool_name = get_event_field(event, :tool_name)
Telemetry.emit(
[:codex, :approval, :timeout],
%{duration: duration, system_time: System.system_time()},
%{
tool: tool_name,
originator: :sdk
}
)
end
# Helper to get field from event (handles both structs and maps)
defp get_event_field(%{__struct__: _} = event, field) do
Map.get(event, field)
end
defp get_event_field(event, field) when is_map(event) do
event[field] || event[to_string(field)]
end
end