Nous.Hook.Runner (nous v0.17.1)

Copy Markdown View Source

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

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, true

With 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

run(registry, event, payload)

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)

run_hooks(hooks, event, payload)

@spec run_hooks([Nous.Hook.t()], Nous.Hook.event(), map()) :: Nous.Hook.result()

Run a list of hooks directly (without registry lookup).