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— Callsmodule.handle(event, payload):command— Executes shell command viaNetRunner.run/2with JSON on stdin
Sandbox
Command hooks are not confined by Nous.Sandbox by default. Hooks are
user-authored operator code, not model-authored, and a hook that cannot write
anywhere defeats the point of having a hook. Confining them by default would
also fail closed on every host with no sandbox provider, silently breaking
working deployments.
Operators who run hooks whose contents they do not fully control can opt in:
config :nous, :sandbox_confine_command_hooks, trueWith the flag on, the hook argv is wrapped using
Nous.Sandbox.Policy.resolve(nil) (no Nous.RunContext exists at this
layer, so the policy comes from application config). If no provider can
enforce the requested mode, the hook does not run unconfined: a warning
is logged and an {:error, _} result is returned, which follows the hook's
existing fail_closed semantics — :deny when fail_closed: true,
otherwise the run continues to the next hook.
The flag alone is not enough. :sandbox_mode must also be set: an unset
mode resolves to :danger_full_access, which means the flag is on and hooks
still run unconfined. That combination logs an explicit warning naming both
settings, once per VM.
Unlike Nous.Tools.Bash, hook stderr stays on :consume: the hook protocol
parses stdout as JSON, so merging stderr in would corrupt it. Consequently
Nous.Sandbox.classify/3 is not used on this path — every signature it
matches is written to stderr, which this path never sees. Instead, once the
argv is actually confined (enforcement != :none), any nonzero exit other
than the protocol's own 2 (deny) is treated as :deny regardless of
fail_closed: from stdout alone, "the sandbox denied the hook", "the runner
could not start" and "the hook failed" are indistinguishable, and a hook
that may never have run must not be able to permit the event.
Unconfined hooks (enforcement == :none, the default) keep their historical
behaviour exactly: a nonzero exit other than 2 fails open unless the hook
sets fail_closed: true.
Summary
Functions
Run all matching hooks for an event.
Run a list of hooks directly (without registry lookup).
Functions
@spec run(Nous.Hook.Registry.t() | nil, Nous.Hook.event(), map()) :: Nous.Hook.result()
Run all matching hooks for an event.
Returns the aggregate result:
:allow— all hooks passed (or no hooks registered):denyor{:deny, reason}— a hook blocked the action{:modify, changes}— a hook wants to modify the payload (last modify wins)
@spec run_hooks([Nous.Hook.t()], Nous.Hook.event(), map()) :: Nous.Hook.result()
Run a list of hooks directly (without registry lookup).