AI Agent安全实战:白名单风险分级与人工确认落地指南
为什么AI Agent安全是上线前必须解决的难题
过去半年,聊到AI Agent时,大家最容易兴奋的点是“它终于能自己干活了”。读取仓库、修改代码、调用接口、发送请求——听起来就像把一个实习生直接塞进了终端。
但当真正把Agent部署到生产环境后,团队最先踩的坑往往不是“它不够聪明”,而是“它太敢动手”。一次未截获的delete操作、一个没隔离的外部请求、一个被注入的工具参数,都可能让本应是提效利器的系统,变成新的风险敞口。
观察下来,不少团队在落地Agent时,都会遇到几个典型问题:
- 工具权限泛化:读文件、写文件、执行命令全部开放,Agent一旦判断失误,攻击面急剧扩大。
- 风险等级缺失:查询天气和删除数据库,在系统里被当作同一类调用处理。
- 人工确认环节空白:高风险操作没有二次确认,出了事只能事后翻日志。
- 审计信息碎片化:只知道“某次调用失败了”,但不知道谁发起的、为什么放行、参数是什么。
所以这篇文章不聊大而空的“Agent安全趋势”,只解决一个足够实用的问题:如何在自有Agent系统里,加一层真正能落地的工具安全闸门。
目标很明确:
- 低风险操作自动放行
- 中风险操作做参数校验
- 高风险操作必须人工确认
- 所有动作都有审计记录
下面用一个简化版TypeScript示例,把这套思路完整走一遍。
核心设计原理:策略引擎先行
先说结论:绝不要让Agent直接触碰工具,必须经过一层策略引擎。
这层策略引擎至少完成四项任务:
1. 工具白名单
Agent只能调用系统显式注册过的工具,不能自由拼接任意命令,也不能在运行时动态发现新工具就直接执行。
这一步解决的是“边界”问题。可以理解为给Agent画活动范围:能去哪、能碰什么、不能碰什么,都提前写清楚。
2. 风险分级
每个工具都必须标注风险等级。一套简单但足够实用的分级方法是:
- low:只读查询,比如搜索文档、查看状态
- medium:有限写操作,比如创建草稿、更新缓存
- high:破坏性操作或对外部影响大的操作,比如删文件、发消息、执行shell
风险等级不是装饰,它直接决定后续流程:是否需要人工审批、是否限制参数、是否进入隔离环境。
3. 参数校验
很多事故不是工具本身危险,而是参数危险。比如write_file本身没问题,但如果目标路径跳出了工作目录,风险就完全变了。
因此必须在工具执行前做参数验证:
- 是否缺少必填字段
- 路径是否越界
- 命令是否命中危险关键字
- URL是否指向允许域名
4. 人工确认与审计
高风险操作绝不能让Agent一步到位。正确流程是:
- Agent先生成计划
- 策略层判断风险为high
- 系统返回
requires_confirmation - 人确认后再执行
- 全流程记审计日志
这样做的好处不是“绝对安全”,而是把不可控风险压缩到人能接住的范围内。
实战演示:打造安全闸门
下面做一个简化版安全闸门案例。场景是:我们的Agent可以调用三个工具:
searchDocs:查文档writeDraft:写草稿文件runShell:执行命令
其中runShell一律视为高风险,必须人工确认;writeDraft只能写到指定目录;searchDocs直接放行。
第一步:定义工具和策略
type RiskLevel = 'low' | 'medium' | 'high'
type ToolContext = {
userId: string
workspace: string
requireHumanConfirm: (payload: {
tool: string
reason: string
input: unknown
}) => Promise
audit: (event: AuditEvent) => Promise
}
type AuditEvent = {
tool: string
risk: RiskLevel
status: 'allowed' | 'blocked' | 'confirmed' | 'rejected'
reason: string
input: unknown
createdAt: string
}
type ToolDefinition = {
name: string
risk: RiskLevel
validate: (input: TInput, ctx: ToolContext) => void
run: (input: TInput, ctx: ToolContext) => Promise
} 关键点在于:工具不只包含run逻辑,还必须自带validate和risk。
很多团队会把校验写在业务逻辑里,导致规则分散,后续维护越来越乱。更稳妥的方式是把校验当成工具定义的组成部分。
第二步:实现三个工具
import path from 'node:path'
import fs from 'node:fs/promises'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
const execFileAsync = promisify(execFile)
const tools: Record> = {
searchDocs: {
name: 'searchDocs',
risk: 'low',
validate(input: { keyword: string }) {
if (!input.keyword || input.keyword.trim().length < 2) {
throw new Error('keyword 太短,拒绝执行')
}
},
async run(input) {
return [`找到与 ${input.keyword} 相关的 3 篇文档`]
}
},
writeDraft: {
name: 'writeDraft',
risk: 'medium',
validate(input: { fileName: string; content: string }, ctx) {
const target = path.resolve(ctx.workspace, 'drafts', input.fileName)
const allowedRoot = path.resolve(ctx.workspace, 'drafts')
if (!target.startsWith(allowedRoot)) {
throw new Error('目标路径越界,拒绝写入')
}
if (!input.content || input.content.trim().length === 0) {
throw new Error('content 不能为空')
}
},
async run(input, ctx) {
const target = path.resolve(ctx.workspace, 'drafts', input.fileName)
await fs.mkdir(path.dirname(target), { recursive: true })
await fs.writeFile(target, input.content, 'utf8')
return { sa ved: true, path: target }
}
},
runShell: {
name: 'runShell',
risk: 'high',
validate(input: { command: string; args?: string[] }) {
const blocked = ['rm', 'sudo', 'mkfs', 'shutdown']
if (blocked.includes(input.command)) {
throw new Error(`命中高危命令 ${input.command},禁止执行`)
}
},
async run(input) {
const { stdout, stderr } = await execFileAsync(input.command, input.args ?? [])
return { stdout, stderr }
}
}
} 这里有两个实战细节值得注意。
第一,writeDraft不只是检查文件名,而是把路径resolve成绝对路径后再判断是否仍在允许目录内。很多“目录穿越”漏洞,都是因为只做了字符串包含判断。
第二,runShell这里故意没做“黑名单万能论”。高危命令拦截只是第一层,真正上生产时,最好再叠加容器隔离、只读挂载和网络白名单。别指望一个if判断解决所有安全问题。
第三步:实现统一闸门
async function executeTool(
toolName: string,
input: unknown,
ctx: ToolContext
) {
const tool = tools[toolName]
if (!tool) {
await ctx.audit({
tool: toolName,
risk: 'high',
status: 'blocked',
reason: 'tool_not_registered',
input,
createdAt: new Date().toISOString()
})
throw new Error(`未知工具:${toolName}`)
}
tool.validate(input, ctx)
if (tool.risk === 'high') {
const approved = await ctx.requireHumanConfirm({
tool: tool.name,
reason: 'high_risk_operation',
input
})
await ctx.audit({
tool: tool.name,
risk: tool.risk,
status: approved ? 'confirmed' : 'rejected',
reason: 'manual_confirmation',
input,
createdAt: new Date().toISOString()
})
if (!approved) {
throw new Error('人工确认未通过,终止执行')
}
}
const result = await tool.run(input, ctx)
await ctx.audit({
tool: tool.name,
risk: tool.risk,
status: 'allowed',
reason: 'policy_passed',
input,
createdAt: new Date().toISOString()
})
return result
}至此,核心框架搭建完成。Agent不再直接调用工具,而是统一走executeTool。后续无论接MCP、接内部函数、还是接第三方API,都能复用这一层策略。
第四步:模拟一次实际调用
const ctx: ToolContext = {
userId: 'u_001',
workspace: '/srv/project-a',
async requireHumanConfirm(payload) {
console.log('待人工确认:', payload)
return false // 模拟审批拒绝
},
async audit(event) {
console.log('AUDIT =>', JSON.stringify(event))
}
}
await executeTool('searchDocs', { keyword: 'mcp' }, ctx)
await executeTool('writeDraft', {
fileName: 'weekly-report.md',
content: '# 本周进展n- 完成策略层接入'
}, ctx)
await executeTool('runShell', {
command: 'git',
args: ['status']
}, ctx)前两个调用会正常执行,第三个会先进入人工确认流程。这里模拟的是拒绝,所以git status不会实际运行。你也可以把逻辑改成:只有在工单系统里点击通过,才允许继续执行。
第五步:放到真实项目里时,再补三刀
如果计划把这套方案接进线上,建议再叠加三层防护:
1. 会话级配额
为每个用户、每个Agent、每类工具都加上调用额度。目的不是省钱,而是防止异常循环和批量误操作。
2. 环境隔离
高风险工具尽量放进隔离执行环境。即使Agent出错,也别让它直接触碰宿主机关键目录。
3. 审批上下文
人工确认页面不要只给一个“同意/拒绝”按钮,最好同时展示:
- Agent原始意图
- 工具名称
- 完整参数
- 风险等级
- 预期影响范围
这样确认动作才有实际意义,否则只是把决策压力甩给人工。
最后一句话
AI Agent真正难的,不是让它多会干活,而是让它在该干的时候干,在不该动手的时候停下来。
工程上最容易落地的一套思路,就是今天讲的四步:
- 工具必须注册到白名单
- 每个工具都要标风险等级
- 执行前做参数校验
- 高风险操作走人工确认并写审计日志
这套设计不复杂,但特别适合做第一版安全底座。它不能保证系统永远不出事,但能显著降低“Agent一步走错,把事故直接放大”的概率。
如果你们团队最近正准备把Agent接入真实业务,建议很直接:先别急着追求它能调多少工具,先保证它每多拿一项能力,你都知道怎么收回来。
