工具权限控制

要点

  • 工具权限控制要解决的问题是:谁能调用哪个工具、在什么条件下可以调用
  • 不是所有工具都应该对所有用户开放——删除数据、发送通知这类操作需要按角色区分
  • 权限模型有两种常见思路:RBAC 按角色分配,ABAC 按属性动态计算;工具场景下 RBAC 通常够用
  • 每个工具声明自己需要的权限等级,执行器在调用前做一次检查,不通过就拒绝
  • 权限控制和工具可见性可以联动——用户没权限的工具不出现在发给 LLM 的工具列表里
  • 权限检查失败的错误信息要写清楚原因,方便 LLM 理解并调整策略

1. 从一个问题开始

前面几节实现了工具的注册、Schema 定义和执行。到目前为止,只要 LLM 决定调用某个工具,后端就会执行。但放到真实场景里会碰到一个问题:

假设你的 Agent 系统里有两个工具:query_orders(查询订单)和 delete_order(删除订单)。一个普通用户问「帮我删掉订单 ORD-001」,LLM 判断需要调用 delete_order,后端直接执行了。

这里缺了一层检查——这个用户有没有权限删除这个订单?

工具权限控制在工具执行之前加一道验证,回答三件事:

  1. 这个用户能不能调用这个工具?
  2. 这个用户能不能操作这条数据?
  3. 如果不允许,怎么把拒绝信息反馈给 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 读到并作为下一轮回复的依据。三条原则:

  1. 说清楚为什么被拒绝。 不要只返回 403 Forbidden,写成自然语言:「此工具需要管理员权限,当前用户角色为 user。」
  2. 给出替代方案。 如果有权限更低的同类工具,提一句:「你可以使用 query_orders 查看订单详情。」
  3. 不暴露内部细节。 不要把表名、函数名、堆栈信息放进去。
// 权限不通过时返回给 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.levelallowedRoles,权限信息跟着工具走
  • 执行时检查:调用实际逻辑之前先过权限检查;不通过时返回带原因的错误信息
  • 可见性过滤:组装发给 LLM 的工具列表时过滤掉没权限的工具,减少无效调用
  • 错误处理:返回给 LLM 的错误信息用自然语言写清楚原因和替代方案;服务端单独记录安全日志
  • 中间件封装:权限检查做成 Hono 中间件,从业务逻辑中解耦

权限控制是一个容易后补但不好后改的模块。早期把检查的接口和位置定下来,后续加角色、加条件、加审计日志都是在这个框架里扩展。