Packages
nous
0.16.1
0.17.0
0.16.6
0.16.5
0.16.4
0.16.3
0.16.2
0.16.1
0.16.0
0.15.8
0.15.7
0.15.6
0.15.5
0.15.4
0.15.3
0.15.2
0.15.1
0.15.0
0.14.3
0.14.2
0.14.1
0.14.0
0.13.3
0.13.2
0.13.1
0.13.0
0.12.17
0.12.16
0.12.15
0.12.14
0.12.13
0.12.12
0.12.11
0.12.9
0.12.7
0.12.6
0.12.5
0.12.3
0.12.2
0.12.0
0.11.3
0.11.0
0.10.1
0.10.0
0.9.0
0.8.1
0.8.0
0.7.2
0.7.1
0.7.0
0.5.0
AI agent framework for Elixir with multi-provider LLM support
Current section
Files
Jump to
Current section
Files
lib/nous/hook/runner.ex
defmodule Nous.Hook.Runner do
@moduledoc """
Executes hooks for lifecycle events with support for blocking, modification,
and external command execution.
## Execution Semantics
- **Blocking events** (`:pre_tool_use`, `:pre_request`): short-circuits on first `:deny`
- **Non-blocking events**: all hooks run, results collected
- Hooks with the same priority run sequentially (ordered by registration)
- Each hook has a configurable timeout (default 10s)
## Handler Types
- `:function` — Calls the function directly with `(event, payload)`
- `:module` — Calls `module.handle(event, payload)`
- `:command` — Executes shell command via `NetRunner.run/2` with JSON on stdin
"""
alias Nous.Hook
alias Nous.Hook.Registry
require Logger
@doc """
Run all matching hooks for an event.
Returns the aggregate result:
- `:allow` — all hooks passed (or no hooks registered)
- `:deny` or `{:deny, reason}` — a hook blocked the action
- `{:modify, changes}` — a hook wants to modify the payload (last modify wins)
"""
@spec run(Registry.t() | nil, Hook.event(), map()) :: Hook.result()
def run(nil, _event, _payload), do: :allow
def run(%Registry{} = registry, event, payload) do
hooks = Registry.hooks_for(registry, event, payload)
run_hooks(hooks, event, payload)
end
@doc """
Run a list of hooks directly (without registry lookup).
"""
@spec run_hooks([Hook.t()], Hook.event(), map()) :: Hook.result()
def run_hooks([], _event, _payload), do: :allow
def run_hooks(hooks, event, payload) do
if Hook.blocking_event?(event) do
run_blocking(hooks, event, payload)
else
run_non_blocking(hooks, event, payload)
end
end
# For blocking events, short-circuit on first :deny
defp run_blocking([], _event, _payload), do: :allow
defp run_blocking([hook | rest], event, payload) do
start_time = System.monotonic_time()
:telemetry.execute(
[:nous, :hook, :execute, :start],
%{system_time: System.system_time()},
%{event: event, hook_name: hook.name, hook_type: hook.type}
)
result = execute_hook(hook, event, payload)
duration = System.monotonic_time() - start_time
:telemetry.execute(
[:nous, :hook, :execute, :stop],
%{duration: duration},
%{event: event, hook_name: hook.name, hook_type: hook.type, result: result_type(result)}
)
case result do
:allow ->
run_blocking(rest, event, payload)
:deny ->
Logger.info("Hook #{inspect(hook.name || hook.type)} denied #{event}")
:telemetry.execute(
[:nous, :hook, :denied],
%{},
%{event: event, hook_name: hook.name, hook_type: hook.type}
)
:deny
{:deny, reason} = denied ->
Logger.info("Hook #{inspect(hook.name || hook.type)} denied #{event}: #{reason}")
:telemetry.execute(
[:nous, :hook, :denied],
%{},
%{event: event, hook_name: hook.name, hook_type: hook.type, reason: reason}
)
denied
{:modify, changes} ->
# Apply modification to payload, continue with remaining hooks
updated_payload = Map.merge(payload, changes)
run_blocking(rest, event, updated_payload)
{:error, reason} ->
Logger.warning(
"Hook #{inspect(hook.name || hook.type)} errored on #{event}: #{inspect(reason)}"
)
# Errors in hooks don't block — fail open
run_blocking(rest, event, payload)
end
end
# For non-blocking events, run all hooks and collect modifications
defp run_non_blocking(hooks, event, payload) do
Enum.reduce(hooks, :allow, fn hook, acc ->
start_time = System.monotonic_time()
:telemetry.execute(
[:nous, :hook, :execute, :start],
%{system_time: System.system_time()},
%{event: event, hook_name: hook.name, hook_type: hook.type}
)
result = execute_hook(hook, event, payload)
duration = System.monotonic_time() - start_time
:telemetry.execute(
[:nous, :hook, :execute, :stop],
%{duration: duration},
%{event: event, hook_name: hook.name, hook_type: hook.type, result: result_type(result)}
)
case result do
:allow ->
acc
{:modify, changes} ->
# Merge modifications (last writer wins for conflicts)
case acc do
{:modify, existing} -> {:modify, Map.merge(existing, changes)}
_ -> {:modify, changes}
end
{:error, reason} ->
Logger.warning(
"Hook #{inspect(hook.name || hook.type)} errored on #{event}: #{inspect(reason)}"
)
acc
_ ->
acc
end
end)
end
# Execute a single hook based on its type
defp execute_hook(%Hook{type: :function, handler: fun}, event, payload)
when is_function(fun, 2) do
try do
fun.(event, payload)
rescue
e ->
Logger.warning("Function hook raised: #{Exception.message(e)}")
{:error, e}
catch
kind, reason ->
Logger.warning("Function hook threw #{kind}: #{inspect(reason)}")
{:error, {kind, reason}}
end
end
defp execute_hook(%Hook{type: :module, handler: module}, event, payload) when is_atom(module) do
try do
Code.ensure_loaded!(module)
module.handle(event, payload)
rescue
e ->
Logger.warning("Module hook #{inspect(module)} raised: #{Exception.message(e)}")
{:error, e}
end
end
# Command hook handler MUST be a [program | args] list. The previous
# API accepted a raw string and ran it via `sh -c`, which means any
# caller-controllable handler value became RCE through shell expansion.
# Lists bypass the shell entirely.
defp execute_hook(
%Hook{type: :command, handler: [program | _] = argv, timeout: timeout},
event,
payload
)
when is_binary(program) do
execute_command_hook(argv, event, payload, timeout)
end
defp execute_hook(%Hook{type: :command, handler: handler}, _event, _payload) do
Logger.warning(
"Command hook handler must be a [program | args] list of binaries, got: #{inspect(handler)}. " <>
"Raw shell strings are no longer accepted - they're an RCE risk if the value " <>
"is ever set from user input or config. Convert to e.g. [\"python3\", \"scripts/check.py\"]."
)
{:error, :invalid_command_handler}
end
defp execute_hook(hook, _event, _payload) do
Logger.warning("Invalid hook configuration: #{inspect(hook)}")
{:error, :invalid_hook}
end
# Execute a command hook via NetRunner. The argv list is passed
# directly - no shell, no expansion.
defp execute_command_hook(argv, event, payload, timeout) do
json_input =
JSON.encode!(%{
event: event,
payload: sanitize_payload(payload)
})
try do
case NetRunner.run(argv, input: json_input, timeout: timeout) do
{output, 0} ->
parse_command_output(output)
{_output, 2} ->
:deny
{:error, :timeout} ->
Logger.warning("Command hook timed out after #{timeout}ms: #{inspect(argv)}")
{:error, :timeout}
{output, exit_code} ->
Logger.warning("Command hook exited with code #{exit_code}: #{String.trim(output)}")
# Non-0/2 exit codes fail open
:allow
end
rescue
e ->
Logger.warning("Command hook failed: #{Exception.message(e)}")
{:error, e}
end
end
# Parse stdout from command hook as JSON
defp parse_command_output(""), do: :allow
defp parse_command_output(output) do
output = String.trim(output)
case JSON.decode(output) do
{:ok, %{"result" => "deny", "reason" => reason}} ->
{:deny, reason}
{:ok, %{"result" => "deny"}} ->
:deny
{:ok, %{"result" => "allow"}} ->
:allow
{:ok, %{"result" => "modify", "changes" => changes}} when is_map(changes) ->
{:modify, changes}
{:ok, _} ->
:allow
{:error, _} ->
# Non-JSON output treated as allow
:allow
end
end
# Remove non-serializable values from payload before JSON encoding
defp sanitize_payload(payload) when is_map(payload) do
payload
|> Enum.reject(fn {_k, v} -> is_function(v) or is_pid(v) or is_reference(v) end)
|> Enum.map(fn {k, v} -> {to_string(k), sanitize_value(v)} end)
|> Map.new()
end
defp sanitize_value(v) when is_map(v), do: sanitize_payload(v)
defp sanitize_value(v) when is_list(v), do: Enum.map(v, &sanitize_value/1)
defp sanitize_value(v) when is_atom(v), do: to_string(v)
defp sanitize_value(v) when is_tuple(v), do: Tuple.to_list(v) |> Enum.map(&sanitize_value/1)
defp sanitize_value(v), do: v
defp result_type(:allow), do: :allow
defp result_type(:deny), do: :deny
defp result_type({:deny, _}), do: :deny
defp result_type({:modify, _}), do: :modify
defp result_type({:error, _}), do: :error
defp result_type(_), do: :unknown
end