给 AI 工具装闸门:权限、人工确认与审计日志怎么落地
先说结论
把大模型接到内部系统上,真正危险的不是它答错,而是它做错。问答错了顶多重来一次;工具调用错了——建了单、发了通知、改了数据——是要人去收拾的。
所以我做这类工具时,不把精力放在”让模型更聪明”,而是放在让它出错时不至于造成破坏。落到代码上就是三层闸门:
- 权限(scope):每个工具声明它需要什么权限,调用方(人/角色)持有什么权限,不匹配就拒绝;
- 人工确认(confirmation):不可逆操作必须由人点头,而且这个点头不能由模型自己给;
- 审计(audit):每一次调用都留下可追溯的记录——谁、在什么角色下、调了什么、结果如何。
下面用一个最小的 MCP 风格服务(stdio + JSON-RPC,零依赖)说明怎么落。
一、校验与执行必须分离
工具调用只有两个结果:过闸门,或者被拦下。我的写法是先校验、后执行,校验层完全不碰业务:
1 | def call_tool(self, name, args, confirmed: bool): |
这里有两个刻意的选择:
- 未知工具默认拒绝(fail-closed)。新增工具忘了配权限,结果是”用不了”,而不是”谁都能用”。
- **拒绝对不是抛异常,而是返回结构化的
rule+reason**。这样前端能给出可执行的提示(”请先申请 tickets.write 权限”),日志能按 rule 聚合,用户不用猜。
二、模型不能自己决定”已确认”
这是整套机制里最容易做错的一点。如果 confirmed 只是工具参数的一部分,那么模型完全可以自己填 confirmed: true —— 闸门就形同虚设。
确认信号必须来自闸门之外:由人在界面上点、由带外通道回填、或者由服务端根据会话状态注入。模型只能”请求确认”,不能”提供确认”。
配套的一条纪律:确认的粒度是操作,不是会话。一次点头只对那一次不可逆操作有效,不能”这次确认了,后面十次都算”。
三、审计用指纹,不落明文
审计日志要回答”谁在什么时候做了什么”,但不能变成新的泄密渠道。我的做法:
1 | {"ts":"2026-10-01T22:41:03+08:00","role":"operator","tool":"create_ticket", |
参数只记 sha256,需要复盘时拿手上的参数去比对;不把用户的原文(可能含手机号、金额、密钥)写进日志。审计文件用 JSONL 追加写,天然可 grep、可入库。
四、策略外置:改授权面不用改代码
权限关系写在一份 policy.json 里,服务启动时加载:
1 | { |
好处很实际:加一个角色、收窄一个权限,是配置变更而不是发版。生产里这份策略应该来自配置中心或数据库,并且策略本身的变更也要留审计——否则闸门自己就成了后门。
五、实测:把规则钉成断言
我给它写了 15 条端到端断言,跑一个文件就能全部验证,其中几条最关键:
| 断言 | 期望 |
|---|---|
reader 调 search_docs |
✅ 允许 |
reader 调 create_ticket |
❌ 拒绝,rule=scope_missing |
operator 调 create_ticket 不带确认 |
❌ 拒绝,rule=confirm_required |
operator 调 create_ticket 带确认 |
✅ 允许 |
| admin 调不可逆工具,不带确认 | ❌ 仍然拒绝(admin 不豁免确认) |
| 调用未注册工具 | ❌ 拒绝,rule=unknown_tool |
| 审计文件行数 | 等于调用次数(含被拒的) |
最后一条特别重要:**”被拒绝的调用也要留痕”**。只看成功记录,你永远不知道有人在敲门。
六、几个踩过的坑
- 我第一版把
whoami工具忘写了处理函数——工具列表里有、调用就崩。是这个自测把它抓出来的,不是我的自信。能跑的测试比自信值钱。 - 别把权限写死在业务代码里。第一版我把 role 判断塞在工具实现里,加一个角色要改五处;拆成策略文件之后清爽了。
- 确认提示要写清后果。”确认执行?”是废话,”该操作会向 12 位同事发送通知,且不可撤回”才是信息。
- 平仓/撤销类操作要单独设计。风控的目标是防止乱开仓,不是阻止逃跑——只拦开仓,不拦平仓。这个原则在交易系统里是铁律,在工具闸门里同样成立。
小结
给 AI 工具装闸门,技术难度不高,难的是取舍:默认拒绝还是默认放行、确认由谁给、审计记多少。我的选择是——默认拒绝、由人确认、只记指纹。
这三条不一定适合你的场景,但至少让”模型乱来”这件事,从”可能发生”变成”必须绕过三道明确的门”。
文中的服务骨架与 15 条断言都是我自己写的,跑得起来;不涉及任何在职公司的代码与数据。