Packages
nous
0.12.13
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/plugins/input_guard.ex
defmodule Nous.Plugins.InputGuard do
@moduledoc """
Modular malicious input classifier plugin.
InputGuard detects prompt injection, jailbreak attempts, and other malicious inputs
using a composable strategy pattern. Detection backends, aggregation modes, and
policy actions are all configurable.
## Architecture
User Input → InputGuard (before_request hook)
├─ Strategy 1: Pattern matching
├─ Strategy 2: LLM Judge
├─ Strategy N: Custom function
↓
Aggregator (any / majority / all)
↓
Policy (block / warn / log / callback)
↓
Modified Context (or halted execution)
## Configuration
Store configuration in `deps` under the `:input_guard_config` key:
agent = Nous.new("openai:gpt-4",
plugins: [Nous.Plugins.InputGuard]
)
{:ok, result} = Nous.run(agent, "Hello",
deps: %{
input_guard_config: %{
strategies: [
{Nous.Plugins.InputGuard.Strategies.Pattern, []},
{Nous.Plugins.InputGuard.Strategies.LLMJudge, model: "openai:gpt-4o-mini"},
{MyApp.InputGuard.Blocklist, words: ["hack", "exploit"]}
],
policy: %{suspicious: :warn, blocked: :block},
aggregation: :any,
short_circuit: false,
on_violation: &MyApp.log_violation/1,
skip_empty: true
}
}
)
## Configuration Options
* `:strategies` — List of `{module, keyword_opts}` tuples. Each module must
implement `Nous.Plugins.InputGuard.Strategy`. Default: `[{Strategies.Pattern, []}]`
* `:policy` — Map of severity to action. Default: `%{suspicious: :warn, blocked: :block}`
* `:aggregation` — How to combine results from multiple strategies.
`:any` (default) flags if any strategy flags, `:majority` if more than half flag,
`:all` only if every strategy flags.
* `:short_circuit` — When `true`, stops running strategies on first `:blocked` result.
Default: `false`
* `:on_violation` — Optional callback function `fn result -> ... end` called when
input is flagged.
* `:skip_empty` — Skip checking empty or whitespace-only messages. Default: `true`
## Streaming Limitation
InputGuard operates via the `before_request` plugin hook, which is not invoked
during `run_stream` in AgentRunner. When using streaming, InputGuard will not
apply — validate input before calling `run_stream` if needed.
"""
@behaviour Nous.Plugin
require Logger
alias Nous.Agent.Context
alias Nous.Message
alias Nous.Plugins.InputGuard.{Policy, Result}
@impl true
def init(_agent, ctx) do
config = get_config(ctx)
# Initialize last_checked_index to track which messages we've already checked.
# This prevents re-checking the same user message on tool-call loop iterations.
last_checked = Map.get(config, :last_checked_index, -1)
ctx = Context.merge_deps(ctx, %{input_guard_last_checked: last_checked})
ctx
end
@impl true
def before_request(_agent, ctx, tools) do
config = get_config(ctx)
case find_unchecked_user_input(ctx) do
nil ->
{ctx, tools}
{input, index} ->
# Update the checked index
ctx = Context.merge_deps(ctx, %{input_guard_last_checked: index})
# Skip empty input if configured
if skip_empty?(config) && blank?(input) do
{ctx, tools}
else
run_guard(input, ctx, tools, config)
end
end
end
# --- Private ---
defp get_config(ctx) do
Map.get(ctx.deps, :input_guard_config, %{})
end
defp find_unchecked_user_input(ctx) do
last_checked = Map.get(ctx.deps, :input_guard_last_checked, -1)
ctx.messages
|> Enum.with_index()
|> Enum.reverse()
|> Enum.find_value(fn {msg, idx} ->
if msg.role == :user && idx > last_checked do
{extract_text(msg), idx}
end
end)
end
defp extract_text(%Message{content: content}) when is_binary(content), do: content
defp extract_text(%Message{content: parts}) when is_list(parts) do
parts
|> Enum.filter(fn
%{type: :text} -> true
_ -> false
end)
|> Enum.map_join(" ", & &1.content)
end
defp extract_text(_), do: ""
defp skip_empty?(config), do: Map.get(config, :skip_empty, true)
defp blank?(str), do: String.trim(str) == ""
defp run_guard(input, ctx, tools, config) do
strategies = Map.get(config, :strategies, [{__MODULE__.Strategies.Pattern, []}])
short_circuit = Map.get(config, :short_circuit, false)
results = run_strategies(strategies, input, ctx, short_circuit)
aggregated = aggregate(results, config)
# Fire on_violation callback if flagged
if aggregated.severity != :safe do
fire_on_violation(aggregated, config)
end
# Apply policy
Policy.apply(aggregated, ctx, tools, config)
end
defp run_strategies(strategies, input, ctx, true = _short_circuit) do
Enum.reduce_while(strategies, [], fn {mod, opts}, acc ->
case safe_check(mod, input, opts, ctx) do
{:ok, %Result{severity: :blocked} = result} ->
{:halt, [result | acc]}
{:ok, result} ->
{:cont, [result | acc]}
:error ->
{:cont, acc}
end
end)
|> Enum.reverse()
end
defp run_strategies(strategies, input, ctx, false = _short_circuit) do
strategies
|> Task.async_stream(
fn {mod, opts} -> safe_check(mod, input, opts, ctx) end,
timeout: 30_000,
on_timeout: :kill_task
)
|> Enum.reduce([], fn
{:ok, {:ok, result}}, acc -> [result | acc]
{:ok, :error}, acc -> acc
{:exit, _reason}, acc -> acc
end)
|> Enum.reverse()
end
defp safe_check(mod, input, opts, ctx) do
mod.check(input, opts, ctx)
rescue
e ->
Logger.warning("InputGuard: Strategy #{inspect(mod)} failed: #{Exception.message(e)}")
:error
catch
kind, reason ->
Logger.warning(
"InputGuard: Strategy #{inspect(mod)} failed: #{inspect(kind)} #{inspect(reason)}"
)
:error
end
defp aggregate([], _config), do: %Result{severity: :safe}
defp aggregate(results, config) do
mode = Map.get(config, :aggregation, :any)
do_aggregate(results, mode)
end
defp do_aggregate(results, :any) do
# Return the most severe result
results
|> Enum.sort_by(&severity_rank/1, :desc)
|> List.first()
|> case do
%Result{severity: :safe} = r -> r
result -> result
end
end
defp do_aggregate(results, :majority) do
flagged = Enum.count(results, &(&1.severity != :safe))
total = length(results)
if flagged > total / 2 do
results
|> Enum.reject(&(&1.severity == :safe))
|> Enum.sort_by(&severity_rank/1, :desc)
|> List.first()
else
%Result{severity: :safe}
end
end
defp do_aggregate(results, :all) do
if Enum.all?(results, &(&1.severity != :safe)) do
results
|> Enum.sort_by(&severity_rank/1, :desc)
|> List.first()
else
%Result{severity: :safe}
end
end
defp severity_rank(%Result{severity: :safe}), do: 0
defp severity_rank(%Result{severity: :suspicious}), do: 1
defp severity_rank(%Result{severity: :blocked}), do: 2
defp fire_on_violation(result, config) do
case Map.get(config, :on_violation) do
fun when is_function(fun, 1) -> fun.(result)
_ -> :ok
end
rescue
e ->
Logger.warning("InputGuard: on_violation callback failed: #{Exception.message(e)}")
end
end