工具权限控制
要点
- 工具权限控制要解决的问题是:谁能调用哪个工具、在什么条件下可以调用
- 不是所有工具都应该对所有用户开放——删除数据、发送通知这类操作需要按角色区分
- 权限模型有两种常见思路:RBAC 按角色分配,ABAC 按属性动态计算;工具场景下 RBAC 通常够用
- 每个工具声明自己需要的权限等级,执行器在调用前做一次检查,不通过就拒绝
- 权限控制和工具可见性可以联动——用户没权限的工具不出现在发给 LLM 的工具列表里
- 权限检查失败的错误信息要写清楚原因,方便 LLM 理解并调整策略
1. 从一个问题开始
前面几节实现了工具的注册、Schema 定义和执行。到目前为止,只要 LLM 决定调用某个工具,后端就会执行。但放到真实场景里会碰到一个问题:
假设你的 Agent 系统里有两个工具:query_orders(查询订单)和 delete_order(删除订单)。一个普通用户问「帮我删掉订单 ORD-001」,LLM 判断需要调用 delete_order,后端直接执行了。
这里缺了一层检查——这个用户有没有权限删除这个订单?
工具权限控制在工具执行之前加一道验证,回答三件事:
- 这个用户能不能调用这个工具?
- 这个用户能不能操作这条数据?
- 如果不允许,怎么把拒绝信息反馈给 LLM?
2. 需要权限控制的场景
按用户角色区分
- 普通用户:查订单、查物流、提交退款申请
- 客服人员:额外能查看其他用户信息、手动触发退款
- 管理员:能删除订单、修改商品价格、批量操作
按工具敏感度区分
- 低风险:查询类工具,只读数据,无副作用
- 中风险:创建、修改类工具,可撤销
- 高风险:删除、批量操作、发送通知,不可逆或影响范围大
低风险工具对所有已登录用户开放,高风险工具通常只有管理员能用。
按数据归属区分
同一个工具,不同用户能操作的数据范围不同。普通用户只能查自己的订单,客服能查任意用户的订单,管理员能改能删任意订单。这一层叫「数据权限」,在工具权限基础上多了一个维度。
3. 权限模型选择:RBAC
RBAC(Role-Based Access Control)的思路:先定义角色,再给角色分配权限,最后把用户放到角色里。
// src/permissions/roles.ts
export type UserRole = 'user' | 'support' | 'admin'
export const rolePermissions: Record<UserRole, {
tools: string[]
dataScope: 'self' | 'all'
}> = {
user: {
tools: ['query_orders', 'query_logistics', 'submit_refund'],
dataScope: 'self'
},
support: {
tools: [
'query_orders', 'query_logistics', 'submit_refund',
'view_user_info', 'manual_refund'
],
dataScope: 'all'
},
admin: {
tools: [
'query_orders', 'query_logistics', 'submit_refund',
'view_user_info', 'manual_refund',
'delete_order', 'update_product', 'batch_operation'
],
dataScope: 'all'
}
}RBAC 的好处是直接。看到用户属于 support 角色,马上知道他能用哪些工具。新增工具时只要在对应角色下加一行就行。ABAC(按属性动态计算)更灵活,但规则一多容易变复杂。工具场景下 RBAC 够用,需要更细粒度时再叠加条件判断。
4. 工具级别的权限配置
每个工具在定义时声明自己需要的权限等级,权限信息和工具逻辑放在一起,新增工具时不容易漏掉。
4.1 定义权限等级和工具类型
// src/permissions/levels.ts
export type PermissionLevel = 'public' | 'authenticated' | 'restricted' | 'admin'// src/tools/types.ts
import type { PermissionLevel } from '../permissions/levels'
import type { UserRole } from '../permissions/roles'
export interface ToolDefinition {
name: string
description: string
inputSchema: Record<string, unknown>
permission: {
level: PermissionLevel
// restricted 级别时,指定允许的角色列表
allowedRoles?: UserRole[]
}
}4.2 定义工具时带上权限
// src/tools/order-tools.ts
export const queryOrdersTool: ToolDefinition = {
name: 'query_orders',
description: '查询订单列表。支持按状态、日期筛选。',
inputSchema: { /* ... */ },
permission: { level: 'authenticated' } // 登录就能查
}
export const deleteOrderTool: ToolDefinition = {
name: 'delete_order',
description: '删除指定订单。此操作不可逆,仅管理员可使用。',
inputSchema: { /* ... */ },
permission: { level: 'admin' } // 只有管理员能删
}
export const manualRefundTool: ToolDefinition = {
name: 'manual_refund',
description: '手动触发退款,将款项退回用户原支付账户。',
inputSchema: { /* ... */ },
permission: {
level: 'restricted',
allowedRoles: ['support', 'admin'] // 客服和管理员都能操作
}
}public 无需登录,authenticated 登录即可,restricted 需要特定角色,admin 仅管理员。每个工具自报等级,执行器检查时不需要硬编码工具名。
5. 执行时的权限检查
5.1 从请求中获取用户信息
权限检查的前提是知道当前请求是谁发的。通常在鉴权中间件里完成:
// src/middleware/auth.ts
import { createMiddleware } from 'hono/factory'
import { verify } from 'hono/jwt'
import type { UserRole } from '../permissions/roles'
export const authMiddleware = createMiddleware<{
Bindings: { JWT_SECRET: string }
Variables: { user: { id: string; role: UserRole } | null }
}>(async (c, next) => {
const header = c.req.header('Authorization')
if (!header?.startsWith('Bearer ')) {
c.set('user', null)
await next()
return
}
try {
const payload = await verify(header.slice(7), c.env.JWT_SECRET)
c.set('user', { id: payload.sub as string, role: payload.role as UserRole })
} catch {
c.set('user', null)
}
await next()
})经过这个中间件后,后续通过 c.get('user') 拿到当前用户信息。未登录或 token 无效时值为 null。
5.2 权限检查函数
// src/permissions/checker.ts
import type { ToolDefinition } from '../tools/types'
import type { UserRole } from './roles'
export interface PermissionResult {
allowed: boolean
reason?: string
}
export function checkPermission(
user: { id: string; role: UserRole } | null,
tool: ToolDefinition
): PermissionResult {
const { level, allowedRoles } = tool.permission
if (level === 'public') return { allowed: true }
if (!user) {
return { allowed: false, reason: '此工具需要登录才能使用,请先让用户登录。' }
}
if (level === 'authenticated') return { allowed: true }
if (level === 'admin' && user.role !== 'admin') {
return {
allowed: false,
reason: `此工具仅限管理员使用,当前用户角色为「${user.role}」,权限不足。`
}
}
if (level === 'restricted' && !allowedRoles?.includes(user.role)) {
return {
allowed: false,
reason: `此工具仅对以下角色开放:${allowedRoles?.join('、') ?? '无'},当前角色为「${user.role}」。`
}
}
return { allowed: true }
}拒绝时返回的 reason 不是 debug 日志,而是要返回给 LLM 的。LLM 读到原因后可以调整策略——告诉用户「需要管理员权限」,或者换一个权限允许的工具。
5.3 接入工具执行器
权限检查放在工具执行之前,不通过就直接返回错误结果:
// src/tools/executor.ts
export async function executeTool(
toolName: string,
input: Record<string, unknown>,
user: { id: string; role: string } | null
): Promise<{ success: boolean; result?: unknown; error?: string }> {
const tool = toolRegistry.get(toolName)
if (!tool) return { success: false, error: `未找到名为「${toolName}」的工具。` }
// 权限检查——在实际执行之前
const perm = checkPermission(user, tool)
if (!perm.allowed) return { success: false, error: perm.reason }
const handler = toolHandlers.get(toolName)
if (!handler) return { success: false, error: `工具「${toolName}」尚未注册执行函数。` }
try {
const result = await handler(input, { userId: user?.id ?? null })
return { success: true, result }
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : '工具执行失败' }
}
}权限不通过时根本不会进入工具逻辑,省掉了不必要的数据库查询或外部调用。
6. 权限与工具可见性
前面做的是「工具被调用时」拦截。但还有一个更前置的问题:LLM 不应该知道用户没权限调用的工具存在。
如果 LLM 看到工具列表里有 delete_order,它可能在用户请求删除时尝试调用,调用后才发现权限不足。白白浪费一次 API 调用,用户体验也不好。更好的做法是在组装工具列表时就把没权限的工具过滤掉。
// src/tools/visibility.ts
import type { ToolDefinition } from './types'
import { checkPermission } from '../permissions/checker'
export function getVisibleTools(
allTools: ToolDefinition[],
user: { id: string; role: string } | null
): ToolDefinition[] {
return allTools.filter((tool) => checkPermission(user, tool).allowed)
}
export function toAnthropicToolFormat(tools: ToolDefinition[]) {
return tools.map((t) => ({
name: t.name,
description: t.description,
input_schema: t.inputSchema
}))
}在路由中使用:
// src/routes/agent.ts
app.post('/api/agent/chat', authMiddleware, async (c) => {
const user = c.get('user')
const { message } = await c.req.json()
// 根据权限过滤工具列表,LLM 不知道被过滤掉的工具存在
const visible = getVisibleTools(allTools, user)
const response = await callAnthropic(message, toAnthropicToolFormat(visible))
if (response.toolUse) {
const result = await executeTool(response.toolUse.name, response.toolUse.input, user)
// 把结果返回给 LLM 继续对话
}
return c.json({ reply: response.text })
})即使做了可见性过滤,executeTool 里的权限检查也不能去掉。LLM 可能 hallucinate 出一个被过滤掉的工具名。执行器里的权限检查是第二道防线。
7. 权限错误处理
7.1 错误信息的写法
权限不通过时返回给 LLM 的错误信息需要仔细处理——这段文字会被 LLM 读到并作为下一轮回复的依据。三条原则:
- 说清楚为什么被拒绝。 不要只返回
403 Forbidden,写成自然语言:「此工具需要管理员权限,当前用户角色为 user。」 - 给出替代方案。 如果有权限更低的同类工具,提一句:「你可以使用 query_orders 查看订单详情。」
- 不暴露内部细节。 不要把表名、函数名、堆栈信息放进去。
// 权限不通过时返回给 LLM 的 tool_result
return {
success: false,
error: '权限不足:delete_order 工具仅限管理员使用,当前用户角色为「user」。如需删除订单,请告知用户联系管理员。'
}7.2 服务端安全日志
返回给 LLM 的信息是给模型读的,给人看的日志要单独记录。权限拒绝属于安全事件:
// 在 checkPermission 不通过时
console.warn(JSON.stringify({
event: 'permission_denied',
userId: user?.id ?? 'anonymous',
tool: tool.name,
requiredLevel: tool.permission.level,
timestamp: new Date().toISOString()
}))这些日志可以用于安全审计。某个用户频繁触发权限拒绝,可能存在异常行为。
8. 封装成 Hono 中间件
每个路由都手动调 checkPermission 会产生重复代码。封装成中间件可以统一拦截:
// src/middleware/tool-permission.ts
import { createMiddleware } from 'hono/factory'
import { checkPermission } from '../permissions/checker'
import type { ToolDefinition } from '../tools/types'
export const toolPermissionMiddleware = createMiddleware<{
Variables: {
user: { id: string; role: string } | null
currentTool: ToolDefinition
}
}>(async (c, next) => {
const user = c.get('user')
const tool = c.get('currentTool')
if (!tool) { await next(); return }
const result = checkPermission(user, tool)
if (!result.allowed) {
return c.json({ error: 'permission_denied', message: result.reason }, 403)
}
await next()
})路由中使用:
// src/routes/tools.ts
app.post('/api/tools/delete-order',
async (c, next) => { c.set('currentTool', deleteOrderTool); await next() },
toolPermissionMiddleware,
async (c) => {
// 走到这里说明权限已通过,直接写业务逻辑
const body = await c.req.json()
// ...
}
)权限检查从业务逻辑中抽离出来。后续调整权限策略改中间件就行,业务代码不用动。
总结
这篇把工具权限控制拆成了几个层面:
- 场景:按角色区分谁能用、按敏感度区分哪些工具要管控、按数据归属区分能操作哪些数据
- 模型选择:RBAC 按角色分配,思路直接,工具场景够用;需要更细粒度时叠加条件判断
- 工具级配置:每个工具声明
permission.level和allowedRoles,权限信息跟着工具走 - 执行时检查:调用实际逻辑之前先过权限检查;不通过时返回带原因的错误信息
- 可见性过滤:组装发给 LLM 的工具列表时过滤掉没权限的工具,减少无效调用
- 错误处理:返回给 LLM 的错误信息用自然语言写清楚原因和替代方案;服务端单独记录安全日志
- 中间件封装:权限检查做成 Hono 中间件,从业务逻辑中解耦
权限控制是一个容易后补但不好后改的模块。早期把检查的接口和位置定下来,后续加角色、加条件、加审计日志都是在这个框架里扩展。