Current section
Files
Jump to
Current section
Files
lib/cmdc/plugin/builtin/human_approval.ex
defmodule CMDC.Plugin.Builtin.HumanApproval do
@moduledoc """
P0 人类审批插件 — 高风险工具执行前等待人类确认(HITL)。
拦截 `before_tool` 事件,对指定工具(默认 `"shell"`)的非白名单命令
广播 `{:approval_required, approval_map}` 事件并阻止工具执行。
审批信息直接以 map 形式内嵌在事件 payload 中,无需独立 struct:
```elixir
%{
id: "a1b2c3d4",
tool: "shell",
args: %{"command" => "rm /tmp/test"},
session_id: "...",
hint: "请回复「确认执行:rm /tmp/test」...",
requested_at: 1_700_000_000_000
}
```
## 两种审批模式(共存)
### 模式 A — 外部 API 审批(管理后台推荐)
```
HumanApproval 拦截 {:before_tool, "shell", args}
│ 广播 {:approval_required, approval_map}
│ block_tool → Agent 回到 idle
│
外部系统收到事件,管理员审批
│
向 Pipeline 发送 {:tool_approved, approval_id} 事件
└── 放行 → 命令进入 pending_approvals,下次 before_tool 自动通过
```
### 模式 B — 用户 re-prompt 审批(Bot 对话场景)
```
用户: "帮我删除 /tmp/test"
│ block_tool,hint 提示用户回复确认前缀
│
用户: "确认执行:rm /tmp/test"
│ before_prompt 识别 confirm_prefix → 命令进入 pending_approvals
│ 下次 before_tool 自动放行
```
## 配置
{CMDC.Plugin.Builtin.HumanApproval,
require_approval_for: ["shell"], # 需要审批的工具名列表
safe_commands: [ # 免审批命令(正则或字符串前缀)
~r/^curl\\s/,
~r/^ls(\\s|$)/,
~r/^pwd$/,
"echo "
],
confirm_prefix: "确认执行:", # 模式 B 的确认前缀
timeout_ms: 120_000 # 审批超时(默认 2 分钟,0 表示不超时)
}
"""
@behaviour CMDC.Plugin
require Logger
@dialyzer [{:nowarn_function, status_label: 1}]
@default_confirm_prefix "确认执行:"
@default_require_approval_for ["shell"]
@default_timeout_ms 120_000
# ==========================================================================
# Plugin Callbacks
# ==========================================================================
@impl true
def init(opts) do
state = %{
require_approval_for:
Keyword.get(opts, :require_approval_for, @default_require_approval_for),
safe_commands: Keyword.get(opts, :safe_commands, []),
confirm_prefix: Keyword.get(opts, :confirm_prefix, @default_confirm_prefix),
timeout_ms: Keyword.get(opts, :timeout_ms, @default_timeout_ms),
pending_approvals: %{},
awaiting_approvals: %{},
# session-scoped 永久白名单
# 用户回复 :tool_approved_always 时把 {tool_name, command_family} 加入此集合
# 下次同类工具调用自动放行(不再触发审批 prompt)
approve_always_allowlist: MapSet.new()
}
{:ok, state}
end
@impl true
def priority, do: 15
@impl true
def describe, do: "P0 人类审批:高风险工具执行前等待人类确认"
@impl true
def handle_event({:before_prompt, text}, state, _ctx) do
case extract_confirmed_command(text, state.confirm_prefix) do
nil ->
{:continue, state}
command ->
key = normalize(command)
Logger.info("[HumanApproval] 收到确认,放行命令: #{String.slice(command, 0, 60)}")
{cleared_awaiting, cancelled} =
Enum.reduce(state.awaiting_approvals, {%{}, 0}, fn
{aid, {_approval, cmd_key, timer_ref}}, {kept, n} when cmd_key == key ->
cancel_timer(timer_ref)
Logger.debug("[HumanApproval] 模式 B 确认,清理 awaiting id=#{aid}")
{kept, n + 1}
{aid, entry}, {kept, n} ->
{Map.put(kept, aid, entry), n}
end)
if cancelled > 0 do
Logger.debug("[HumanApproval] 清理了 #{cancelled} 个关联的 awaiting_approvals")
end
state = %{
state
| pending_approvals: Map.put(state.pending_approvals, key, true),
awaiting_approvals: cleared_awaiting
}
{:continue, state}
end
end
def handle_event({:before_tool, tool_name, args}, state, ctx) do
if tool_name in state.require_approval_for do
check_tool(tool_name, args, state, ctx)
else
{:continue, state}
end
end
def handle_event({:tool_approved, approval_id}, state, ctx) do
case Map.pop(state.awaiting_approvals, approval_id) do
{nil, _} ->
{:continue, state}
{{approval, command_key, timer_ref}, rest} ->
cancel_timer(timer_ref)
resolved = Map.put(approval, :status, :approved)
Logger.info("[HumanApproval] 审批通过 id=#{approval_id} session=#{ctx.session_id}")
CMDC.EventBus.broadcast(ctx.session_id, {:approval_resolved, resolved})
state = %{
state
| awaiting_approvals: rest,
pending_approvals: Map.put(state.pending_approvals, command_key, true)
}
{:continue, state}
end
end
# :tool_approved_always 永久放行同类命令
def handle_event({:tool_approved_always, approval_id}, state, ctx) do
case Map.pop(state.awaiting_approvals, approval_id) do
{nil, _} ->
{:continue, state}
{{approval, command_key, timer_ref}, rest} ->
cancel_timer(timer_ref)
resolved = approval |> Map.put(:status, :approved) |> Map.put(:scope, :always)
tool_name = Map.get(approval, :tool, "shell")
family = command_family(tool_name, Map.get(approval, :args, %{}))
allowlist_key = {tool_name, family}
Logger.info(
"[HumanApproval] approve_always id=#{approval_id} 加入白名单 #{inspect(allowlist_key)}"
)
CMDC.EventBus.broadcast(ctx.session_id, {:approval_resolved, resolved})
CMDC.EventBus.broadcast(ctx.session_id, {:approval_allowlisted, allowlist_key})
state = %{
state
| awaiting_approvals: rest,
pending_approvals: Map.put(state.pending_approvals, command_key, true),
approve_always_allowlist: MapSet.put(state.approve_always_allowlist, allowlist_key)
}
{:continue, state}
end
end
def handle_event({:tool_rejected, approval_id}, state, ctx) do
resolve_awaiting(approval_id, :rejected, state, ctx)
end
def handle_event({:tool_approval_timeout, approval_id}, state, ctx) do
resolve_awaiting(approval_id, :timeout, state, ctx)
end
def handle_event(_event, state, _ctx), do: {:continue, state}
# ==========================================================================
# 私有 — 审批检查
# ==========================================================================
defp check_tool("shell", args, state, ctx) do
command = Map.get(args, "command", "")
key = normalize(command)
family = command_family("shell", args)
cond do
safe_command?(command, state.safe_commands) ->
{:continue, state}
# approve_always 白名单
MapSet.member?(state.approve_always_allowlist, {"shell", family}) ->
Logger.info("[HumanApproval] approve_always 白名单命中 #{inspect(family)},放行")
{:continue, state}
Map.has_key?(state.pending_approvals, key) ->
Logger.info("[HumanApproval] pending_approvals 命中,放行: #{String.slice(command, 0, 60)}")
{:continue, %{state | pending_approvals: Map.delete(state.pending_approvals, key)}}
true ->
request_approval("shell", args, command, state, ctx)
end
end
defp check_tool(tool_name, args, state, ctx) do
family = command_family(tool_name, args)
if MapSet.member?(state.approve_always_allowlist, {tool_name, family}) do
Logger.info("[HumanApproval] approve_always 白名单命中 #{tool_name},放行")
{:continue, state}
else
request_approval(tool_name, args, inspect(args), state, ctx)
end
end
# 从 args 提取命令家族(用于 approve_always 白名单 key)
# - shell:第一个 token(如 `ls /tmp` → "ls",`git pull && npm install` → "git")
# - 其他工具:`nil`(按 tool_name 全量授权)
defp command_family("shell", %{"command" => cmd}) when is_binary(cmd) do
cmd
|> String.trim_leading()
|> String.split(~r/[\s&|;]/, parts: 2)
|> List.first()
|> case do
"" -> "shell"
nil -> "shell"
first_token -> first_token
end
end
defp command_family(_tool, _args), do: nil
defp request_approval(tool_name, args, display_text, state, ctx) do
hint = build_hint(tool_name, display_text, state.confirm_prefix)
agent_pid = self()
approval = %{
id: generate_id(),
tool: tool_name,
args: args,
session_id: ctx.session_id,
status: :pending,
hint: hint,
requested_at: System.system_time(:millisecond)
}
timer_ref =
if state.timeout_ms > 0 do
Process.send_after(agent_pid, {:cmdc_approval_timeout, approval.id}, state.timeout_ms)
else
nil
end
command_key = normalize(display_text)
awaiting = Map.put(state.awaiting_approvals, approval.id, {approval, command_key, timer_ref})
Logger.warning(
"[HumanApproval] 工具执行被拦截,等待确认 session=#{ctx.session_id} tool=#{tool_name} id=#{approval.id}"
)
CMDC.EventBus.broadcast(ctx.session_id, {:approval_required, approval})
{:block_tool, "需要人类确认后才能执行 #{tool_name} 命令。#{hint}", %{state | awaiting_approvals: awaiting}}
end
# ==========================================================================
# 私有 — 拒绝/超时处理(公用)
# ==========================================================================
defp resolve_awaiting(approval_id, status, state, ctx) do
case Map.pop(state.awaiting_approvals, approval_id) do
{nil, _} ->
{:continue, state}
{{approval, _command_key, timer_ref}, rest} ->
cancel_timer(timer_ref)
resolved = Map.put(approval, :status, status)
Logger.info(
"[HumanApproval] 审批#{status_label(status)} id=#{approval_id} session=#{ctx.session_id}"
)
CMDC.EventBus.broadcast(ctx.session_id, {:approval_resolved, resolved})
{:continue, %{state | awaiting_approvals: rest}}
end
end
# ==========================================================================
# 私有 — 确认 prompt 识别(模式 B)
# ==========================================================================
defp extract_confirmed_command(text, confirm_prefix) do
trimmed = String.trim(text)
if String.starts_with?(trimmed, confirm_prefix) do
cmd =
trimmed
|> String.replace_prefix(confirm_prefix, "")
|> String.trim()
if cmd == "", do: nil, else: cmd
else
nil
end
end
# ==========================================================================
# 私有 — 白名单
# ==========================================================================
defp safe_command?(_command, []), do: false
defp safe_command?(command, safe_commands) do
Enum.any?(safe_commands, fn
%Regex{} = regex -> Regex.match?(regex, command)
prefix when is_binary(prefix) -> String.starts_with?(command, prefix)
end)
end
defp normalize(command) do
command
|> String.trim()
|> String.replace(~r/\s+/, " ")
end
defp build_hint(tool_name, display_text, confirm_prefix) do
short = String.slice(display_text, 0, 80)
"请回复「#{confirm_prefix}#{short}」确认执行 #{tool_name},或忽略此消息取消。"
end
defp generate_id do
:crypto.strong_rand_bytes(8) |> Base.encode16(case: :lower)
end
defp cancel_timer(nil), do: :ok
defp cancel_timer(ref), do: Process.cancel_timer(ref)
defp status_label(:rejected), do: "被拒绝"
defp status_label(:timeout), do: "超时自动拒绝"
defp status_label(other), do: inspect(other)
end