让 AI 查数据不能只靠嘴:Agent 工具调用的安全边界

SailTrack
2026-09-13
点 赞
0
热 度
4
评 论
0
  1. 首页
  2. 后端开发
  3. 让 AI 查数据不能只靠嘴:Agent 工具调用的安全边界

写在前面

“AI 助手"要变得有用,光会聊天不够,得能查真数据:这个员工谁带?我还剩几天年假?本月考勤有没有异常?这就是工具调用(tool call)——模型说"我需要调这个工具”,系统替它执行,把结果喂回去。

但工具一接上真数据,安全问题就换了量级。聊天说错话是体验问题,工具查错数据是安全事故——员工 A 通过 AI 查到员工 B 的假期余额,比 AI 回答得不够流畅严重一百倍。这篇记录我的工具执行器怎么在"能力"和"安全"之间划界。

一、路由:一张 switch 就是工具白名单

工具执行器的核心是个显式的路由:

Object result = switch (binding.toolCode()) {
    case "ORG_GET_USER_SUMMARY"      -> getUserSummary(requiredLong(payload, "userId"));
    case "ORG_GET_DEPT_SUMMARY"      -> getDeptSummary(requiredLong(payload, "deptId"));
    case "ORG_GET_MANAGER_USER_ID"   -> Map.of("managerUserId", ...);
    case "LEAVE_GET_BALANCES"        -> requireLeaveService().getLeaveBalances(...);
    case "LEAVE_GET_AVAILABLE_MINUTES" -> Map.of("availableMinutes", ...);
    case "ATTENDANCE_GET_DAILY_RESULT" -> requireAttendanceService().getDailyResult(...);
    case "KNOWLEDGE_SEARCH_CHUNKS"   -> requireKnowledgeSearchService().searchChunks(...);
    default -> throw new BizException(400, "暂不支持该" + binding.toolType() + "工具执行");
};
return write(objectMapper, Map.of("status", "SUCCESS", "result", result == null ? Map.of() : result));

工具名是静态登记的,switch 没有兜底的"反射找同名方法"。default 分支永远报错——模型幻觉出一个不存在的工具名,得到的不是"尽力执行",而是一句明确的"暂不支持"。

为什么不用反射或动态分发?因为工具列表是这个系统的攻击面清单:安全评审时我要能指着这七个 case 说"AI 能触达的数据就这些"。反射方案让工具面在运行时漂移,清单变成了猜谜。给模型的能力面必须是枚举的、可打印在一张纸上的。

二、最关键的一关:数据越权的防线

执行任何工具前,先过一段看起来不起眼的校验:

// 载荷里的 userId 只允许查本人或直属下属(与工作流数据源同一规则),
// 防止借工具调用跨用户读取他人假期/考勤等数据。传 null 表示不强制(仅限管理端试运行)。
if (operatorUserId != null) {
    JsonNode requested = payload.path("userId");
    if (requested.canConvertToLong()) {
        Long targetUserId = requested.asLong();
        if (!targetUserId.equals(operatorUserId)) {
            OrgUserDetailResponse target = orgDirectoryService.getUserDetail(targetUserId);
            if (!operatorUserId.equals(target.getManagerUserId())) {
                throw new BizException(403, "无权查询该员工信息");
            }
        }
    }
}

规则只有一句:载荷里要查的 userId,必须是调用者本人,或调用者的直属下属——和审批流取数用同一套授权规则。

为什么这道闸如此重要?想象员工对 AI 助手说:"帮我查一下张总的年假余额。"模型不理解什么是隐私,它只会忠实地构造 LEAVE_GET_BALANCES 工具调用、填上它从上下文里知道的 userId。没有这道闸,AI 就是每个员工手里的一次性越权查询器——不用写代码、不用抓包,聊天框里一句话就能拿到全公司的考勤和假期。

两个设计细节:授权规则复用审批流的"直属下属"定义,同一套组织语义只写一遍;管理端试运行时 operatorUserId 传 null 跳过校验——但这条路只存在于管理员调试的入口,员工侧调用永远传真实身份。豁免路径必须显式标注归属,否则它会悄悄变成后门。

三、参数校验:对模型的宽容,对协议的严格

模型返回的工具参数有个著名的坑:数字可能是字符串。协议里定义 userId: 12345,模型有时回的是 "userId": "12345"。我的参数工具函数对此做了宽容:

private Long requiredLong(JsonNode payload, String fieldName) {
    JsonNode node = payload.get(fieldName);
    if (node == null || node.isNull()) {
        throw new BizException(400, fieldName + "不能为空");
    }
    if (node.canConvertToLong()) {
        return node.asLong();
    }
    if (node.isTextual()) {           // 模型经常把数字包成字符串
        String text = node.asText().trim();
        if (StringUtils.hasText(text)) {
            try {
                return Long.parseLong(text);
            } catch (NumberFormatException ignored) { }
        }
    }
    throw new BizException(400, fieldName + "不能为空");
}

宽容的范围划得很准:类型表达可以宽容,内容缺失不容商量。数字的字符串形态可以转,但字段缺失、空值、无法解析一律 400——因为执行器返回给模型的就是这段错误信息,模型会拿着"userId 不能为空"在下一轮自己修正参数。工具的错误信息是给模型看的提示词,写得越具体,模型的自纠能力越强。

四、返回值:白名单字段 + 统一包装

工具查询到数据后,不是把数据库实体一把序列化:

private Map<String, Object> getUserSummary(Long userId) {
    OrgUserSummaryResponse user = orgDirectoryService.getUserSummary(userId);
    Map<String, Object> result = new LinkedHashMap<>();
    result.put("userId", user.getUserId());
    result.put("name", user.getRealName());
    result.put("deptName", user.getPrimaryDeptName());
    result.put("managerName", user.getManagerName());
    result.put("employmentStatus", user.getEmploymentStatus());
    ...
    return result;
}

逐字段的白名单搬运——哪怕响应 DTO 已经是干净的,也要再列一遍要给模型看的字段。这不是偏执:DTO 会演进,今天加一个"手机号"字段,明天模型就开始在回答里念用户的手机号。白名单让"AI 能看到什么"重新变成一行行可评审的代码。

最终返回统一包装成 {"status": "SUCCESS", "result": {...}}——模型读到的每个工具结果都有相同的外壳,它不需要为每个工具学一套响应格式。对模型而言,格式的一致性就是可用性。

五、模块依赖:ObjectProvider 的优雅降级

注入依赖的方式值得注意:

private final ObjectProvider<LeaveService> leaveServiceProvider;
...
private LeaveService requireLeaveService() {
    LeaveService service = leaveServiceProvider.getIfAvailable();
    if (service == null) {
        throw new BizException(501, "假期查询工具未接入");
    }
    return service;
}

用 ObjectProvider 而不是直接注入——假期模块如果因为配置或部署形态缺席,工具执行器仍然能启动,对应工具调用返回 501"未接入"。工具面和模块可用性解耦后,裁剪部署(比如某客户不买考勤)不会拖垮 AI 助手。

六、小结

三点体会:

  1. 工具面要枚举,不要反射。 一张 switch 就是 AI 能力的完整清单——它是安全评审材料,也是运维手册,还是能力规划表。
  2. 授权规则复用业务的规则,不要另起炉灶。 "本人或直属下属"在审批流和工具调用里是同一句话——同一语义两套实现,迟早分叉出漏洞。
  3. 模型的输出按"不可信输入"处理。 字符串数字要容错、幻觉工具要拒绝、返回字段要白名单——工具执行器本质上是模型和数据库之间的海关,查验的是每一票货物。

AI 助手现在每天替员工查几百次假期、考勤和组织信息,零越权记录。模型依旧会偶尔构造出奇怪的参数,但每一笔调用都过闸——让 AI 有能力,和让能力有边界,是同一件事的两半。


让我们忠于理想,让我们面对显示

SailTrack

entp 辩论家

站长

不具版权性
不具时效性

文章内容不具时效性。若文章内容有错误之处,请您批评指正。

目录

欢迎来到SailTrack的站点,为您导航全站动态

65 文章数
11 分类数
5 评论数
60标签数