# `Nous.Hook.Runner`
[🔗](https://github.com/nyo16/nous/blob/v0.17.1/lib/nous/hook/runner.ex#L1)

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`.

# `run`

```elixir
@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)
- `:deny` or `{:deny, reason}` — a hook blocked the action
- `{:modify, changes}` — a hook wants to modify the payload (last modify wins)

# `run_hooks`

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

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
