AI代理(AI Agent)落地时,最刺激的永远是“技能(Skill)调用”那一下。我在某Agent平台做安全支撑时,几乎每天都要回答同一个问题:模型被诱导了怎么办?技能越权了怎么办?恶意输入注入了怎么办?我的答案始终落在两件事上:策略引擎做前置裁决,沙箱隔离做兜底隔离。这篇文章不聊花哨的算法,只讲我们如何把“技能安全执行”拆成可以落地的设计和代码,以及在真实环境里踩过哪些坑。如果你正在做Agent产品、正在设计技能执行框架,或者只是好奇大模型应用怎么在权限上刹住车,接下来的内容值得看完。
1. 为什么AI代理的安全问题不能用老办法解决
1.1 技能执行链上新增的三个风险面
AI代理与传统API服务最大的不同,是它把“意图”和“动作”之间的链条拉长且自动化了。传统API每个接口有固定参数、固定权限,调用方是明确的人或服务;Agent则是由模型基于自然语言生成一个动作序列,再分发到不同技能模块执行。这中间产生了三个传统接口层不会面对的风险面。
第一个风险面是意图与动作的解耦。模型理解的“用户意图”和最终执行的动作,可能被提示注入攻击改写。比如用户要求“帮我查一下销售数据”,模型本来会生成调用内部报表接口的动作,但如果某个外部内容在对话中被注入了恶意指令,模型可能把动作改成“把报表发到某个外部地址”。这意味着我们不能只校验“输入文本”,必须校验“即将执行的动作本身”。
第二个风险面是上下文跨越。Agent通常在同一个会话里连续调用多个工具,前一个调用的输出会成为后一个调用的输入。用户很难感知一次对话里实际发生了多少次跨权限调用。传统系统靠“用户身份+接口权限”就能隔离,但在Agent里,用户身份不变,可实际操作多个系统,权限边界变得很模糊。
第三个风险面是自动执行闭环。为了把产品体验做顺,很多团队默认让Agent自动执行低风险操作,只有高风险操作才转人工。可是“低风险”和“高风险”的划分如果只在写代码时定死,等新增技能时往往跟不上节奏,结果会漏掉一部分需要管控的动作。这个坑我们第一次上线时就踩过,当时只覆盖了几个核心技能,后面每加一个技能都要重新审视权限模型,成本极高。
所以我的结论是:AI代理的安全设计必须重点解决“动作层面的授权”问题,而不是再去强化单点接入的鉴权。单账号的权限体系覆盖不了多步动作链,必须有一个集中式的裁决层,替每一个动作做决策。
1.2 安全目标拆解:不做什么比做什么更重要
设计伊始,我和团队列了一份“绝不做什么”清单,这份清单反而比功能列表更有用。我们约定:绝不允许模型直接执行带副作用的动作,哪怕用户语气再着急,删除、批量发送、修改配置这类操作也必须经过审批;绝不把安全规则写死在业务代码里,规则要能热更新,要能被审计,要能被非开发人员评审;绝不追求“完全自主”,AI代理的体验可以流畅,但高风险动作必须保留人工兜底。
在此基础上,把目标拆成三个可测量的指标:阻断率、误杀率、逃逸率。阻断率指明显恶意或越权的动作被拦截的比例;误杀率指被误拦截的正常请求比例;逃逸率指已发生违规但未被发现的比例。这三个指标互相制约,调策略的时候如果只盯着阻断率,误杀率会飙高;只追求低误杀,逃逸风险又变大。我们后来用策略引擎配合灰度流量,就是为了同时管理这三个指标。
还有一件事要提前想清楚:技能类型差异很大。我们内部把技能分成三个风险等级:只读信息型、受控操作型、高风险变更型。只读型技能即使是访问敏感数据,重点也在数据脱敏和字段级权限;受控操作型技能比如发送邮件、创建工单,重心在目标白名单和频率限制;高风险变更型技能比如删除文件、批量更新数据库,优先级永远是“先审批后执行”。这个三级分类贯穿了后面所有策略和沙箱设计,一开始不把分类定清楚,后面规则会越来越乱。
2. 策略引擎的选型与整体设计
2.1 为什么要把安全决策从代码里抽出来
最早我们确实把安全检查写在每个技能模块里,结果不到两个月就撑不住了。新增一个技能要改代码、发版本、重新走测试,而且每个模块的安全判断标准不统一:有的判断参数是否包含特殊字符,有的只判断数量级,有的干脆只检查文件扩展名。这种嵌入式写法的最大问题是:安全规则和业务逻辑绑定在一起,业务一变,安全就碎。
策略引擎的思路是把“做什么决定”这件事从具体业务代码里抽出来,变成一个独立服务或独立模块。它接收的是“一次动作请求”的结构化描述,输出的是“允许、拒绝、需要人工审批、需要额外验证”的决策。业务代码不关心规则怎么定义,只负责执行决策。
这种解耦带来的直接好处有三个:策略变更可以不经发版热更新;安全团队可以单独审计策略,不用去翻散落各处的代码;决策链路有统一的地方记录,出问题追查路径清晰。我们花了一个迭代周期做重构,把原来散落在技能模块里的几十个安全检查全部迁移到策略引擎,虽然当时看起来多花了一点时间,但后续每次新增技能、每次应对新的攻击手法,都轻松了很多。
2.2 策略语言:从DSL到结构化配置
在设计策略语言时,我们踩过一个不小的坑:一开始想用自研DSL,做一个类似“IF 技能 = send_email AND 收件人 NOT IN 白名单 THEN 需要审批”的语法。原型跑通后,团队里没人愿意维护,写DSL解析器的只有一个人,其他人看一眼就头疼。后来我们果断换成了结构化配置,也就是用JSON和YAML描述策略,再用一个简单的规则引擎来解释。
一个策略条目长这样:
- id: "rule-high-risk-action-001" version: 5 name: "高成本动作必须人工审批" priority: 90 when: all: - field: skill.name op: in value: ["file.delete", "db.truncate", "batch.send_email"] - field: env.is_production op: eq value: true then: decision: require_human_approval reason: "生产环境高危操作,强制双人审批" ttl_minutes: 15这里有几个关键设计点。字段全部来自结构化的“动作请求上下文”,而不是原始对话文本。技能名、目标资源、数据敏感级别、用户角色、租户、环境标签、模型置信度这些都是字段。只允许在特定字段上做匹配,不允许在策略代码里写任意逻辑,这样可审计性才有保障。
操作符尽量简单。我们只保留了eq、neq、in、not_in、exists、regex、cidr_match、time_range这几类,够用且不会把人绕晕。支持regex是因为有些技能要按路径白名单匹配,比如只允许写工作目录下的文件,路径规则必须能做模式匹配。决策字段不只有allow和deny,还引入了require_human_approval和require_secondary_check。前者是转人工审批流,后者是发起一次额外验证请求,比如让用户在App端确认一次动态码。很多动作谈不上“禁止”,但机械地允许又不放心,提供中间态能大幅降低误杀率。
每条策略必须有条目id和版本号。决策日志里必须记录命中的策略id和版本,否则策略一改,回溯就全乱了。这一点我们做过血的教训,早期没有版本字段,规则更新后出了疑似逃逸事件,根本说不清当时到底执行的是哪一套规则。
2.3 决策流程:一次裁决的完整路径
策略引擎的裁决流程,我习惯叫“五步裁决”,从请求进入引擎到返回结果,路径是固定的。
第一步,构建动作请求上下文。Agent侧在决定调用某个技能前,会把动作意图、参数、目标资源、当前用户、所属租户、环境信息、模型标识、trace_id打包成一个标准结构,发给策略引擎。这里特别强调一点:参数必须先做一次规范化,文件路径要转成绝对路径,邮箱要用统一格式,URL要解析出scheme、host、port,避免策略匹配时被格式差异糊弄过去。攻击者经常利用相对路径、URL编码、大小写变体来绕过规则,规范化这步就是第一道过滤网。
第二步,拉取相关策略。引擎先根据租户、环境、技能名三个维度缩小候选策略集,而不是把所有策略拉出来一条条跑。候选集通常压到几十条以内,再做精确匹配。这一步如果没做索引,规则量一上来,性能只会越来越差。
第三步,逐条评估。按照优先级从高到低执行,命中的决策类型如果是deny,直接短路返回;如果命中的是require_human_approval,则继续评估后续低优先级规则,因为低优先级规则里可能有allow白名单,允许某些内部IP或特定用户跳过审批。这个“继续评估”的细节很关键,它保证了白名单和兜底策略可以并存。
第四步,合并决策。合并规则很简单:deny大于一切;require_hman_approval优先于allow;allow直接返回;都没有命中则返回默认deny。注意默认动作是deny,也就是“白名单+默认拒绝”双模式。没有命中任何策略的请求默认拒绝,避免新增技能时因为策略缺失而裸奔。
第五步,记录决策日志并返回。决策日志里要包含请求id、策略版本快照、命中规则、决策结果、耗时、触发原因。这些数据后面审计会用到。我们曾经因为这一步偷懒,日志里没记请求参数,结果事故回溯时只能看到“拒绝”或“允许”,完全还原不了攻击手法的上下文,后来才补上。
2.4 性能优化:三层缓存与异步审计
刚开始,我们把策略引擎做成每次都实时查询规则库,QPS一上来就爆炸。优化思路分三层。
第一层是规则索引。把策略表的id、优先级、适用范围字段做成内存索引,启动时加载全量规则,变更时通过订阅消息增量更新。决策过程几乎不打数据库。我们实测下来,一次决策本地执行耗时中位数在0.5毫秒左右,全链路加网络传输在8毫秒左右,对Agent场景完全够用。
第二层是决策缓存。相同用户、相同技能、相同参数特征(对参数做hash)的请求,在TTL窗口内直接复用决策结果。这个缓存对非敏感只读技能特别有效,命中率能到60%左右。但要注意,高风险技能不能开缓存,或者TTL要极短,一句话的功夫就可能让一个delete操作溜过去。缓存键的设计也值得注意:必须把租户、用户、技能名、参数hash、环境标签都放进去,少一个都可能串权限。
第三层是异步审计。决策日志不阻塞主线流程,先写入本地环形队列,由后台任务批量刷到审计存储。审计存储必须保留原始请求和决策结果,一旦发现逃逸事件,可以回溯当时的完整决策链。性能优化不要牺牲审计完整性,这是底线。
3. 沙箱隔离:兜底的最后一道墙
3.1 为什么有了策略引擎还要沙箱
策略引擎即使再完善,也只是“理论上”拦截风险。现实的残酷在于,规则永远比漏洞慢一步。某种提示注入可能构造出策略引擎认不出来的动作描述;某个技能的代码本身可能有漏洞,被参数触发后执行了模型根本没打算执行的操作;某个依赖库的已知漏洞可能在爬取一个URL时被远程利用。这些情况光靠前置拦截是堵不住的,必须假设“技能代码会被突破”,然后让被突破的代码无路可走。
沙箱想解决的问题不是“允许做什么”,而是“万一跑了不该跑的代码,它能访问到什么”。我们内部有一句话:策略引擎决定让谁进门,沙箱决定进门的人能碰到哪些东西。把沙箱放在技能执行的最底层,相当于给策略决策加了一个保险丝。
3.2 隔离方案选型:三条路径对比
做沙箱隔离,我评估过三类方案,各有取舍。第一类是进程级隔离,基于操作系统提供的namespace和seccomp机制,把子进程限制在独立的mount、pid、network namespace里,再配合只读文件系统和系统调用白名单。优点是资源开销小、启动快,适合高频低风险技能;缺点是隔离强度有限,进程逃逸时风险较大,不适合跑可信度低的代码。
第二类是容器级隔离,在进程隔离基础上叠加容器的镜像和资源限制体系,把整个Agent技能放到容器里跑,通过只读根文件系统、无特权容器、禁用设备挂载等加固项来收紧。优点是隔离性较好,部署运维工具链成熟,适合大多数业务技能;缺点是开销比进程级高一些,启动速度和镜像管理需要额外处理。
第三类是语言级虚拟机隔离,比如用某种沙箱化的运行时来执行技能代码,把外部系统调用翻译成受限操作。优点是隔离边界清晰,很多危险系统调用直接不支持,性能也可以接受;缺点是只能跑特定语言生态的技能,对依赖动态库、系统调用的技能不友好。
我们最终选择的是“轻量容器+seccomp”的混合路线:常规技能跑在容器里,特殊高吞吐技能跑在进程级环境里,两类沙箱都接入统一的策略引擎。选这个方案是因为它在隔离强度、性能、团队认知成本之间平衡得最好。三种方案的快速对比大致如下:
| 方案 | 隔离强度 | 性能开销 | 部署复杂度 | 适用场景 |
|---|---|---|---|---|
| 进程级隔离 | 中 | 低 | 低 | 高频、低风险技能 |
| 容器级隔离 | 较高 | 中 | 中 | 大多数业务技能 |
| 语言级虚拟机 | 高 | 中高 | 高 | 可信度低、需要最强隔离的技能 |
3.3 最小化沙箱环境的落地清单
要让沙箱真正起到兜底作用,光有容器技术还不够,关键在于“最小化”原则。我把它总结成一份清单,照着做基本不会出大问题。
网络必须是白名单模式。容器默认没有网络,技能需要联网时,由沙箱网关在容器外做一层域名和IP白名单检查,只允许访问标记为信任的API域名。不让容器直接出网,是防止数据外泄最重要的一条。文件系统做成只读并挂载临时工作目录。技能的代码和依赖放在只读层里,容器启动后获得一个临时可写目录,大小限制在几百MB以内,进程结束后自动销毁。技能要写文件,只能写进临时目录,路径不能超过预设长度,文件名要做过滤。
系统调用过滤用最小白名单。基础白名单只有read、write、close、fstat、mmap、brk、exit、epoll_wait这些,遇上需要额外系统调用的技能,单独给这个技能所属的策略组增加白名单。不要让所有技能共享一套宽泛的白名单,否则等于没设。资源限制按CPU和内存双上限控制。内存上限设置成技能正常峰值的两倍左右,太紧了会导致初始化失败,太松了又起不到防失控作用。CPU配额和超时时间配合使用,执行超过时限直接杀进程。
一个实际的沙箱配置模板大致长这样:
sandbox: image: agent-skill-base:2024.03 read_only_rootfs: true tmpfs: - /workspace: size=512m,mode=0700 network: disabled seccomp_profile: default-strict resource: cpu: 0.5 memory: 1GiB timeout: 30s capabilities: drop: ["ALL"]3.4 沙箱内外的通信边界
沙箱里执行代码不难,难的是怎么把它“能做的事情”限制在业务真正需要的范围内。我们的做法是:沙箱进程内部不开放通用HTTP客户端,只开放一个Agent执行SDK提供的受限调用接口。SDK内置了带白名单校验的HTTP封装、受限文件读写、格式化输出等能力。技能作者写代码时只能调用这些能力,不能直接发起任意请求。这样一来,沙箱的网络和文件系统都能被策略层控制住,而不是让技能代码自己到处乱飞。
一个容易被忽略的细节是输出序列化。沙箱执行完技能后,返回给Agent的不仅是结果文本,还可能包含大对象、临时文件引用、异常堆栈。如果对这些输出不设限,攻击者可能通过技能把沙箱里的敏感数据打包返回。所以我们在沙箱出口再加一层输出校验:结果大小上限、可序列化类型白名单、禁止返回文件路径、异常信息做脱敏处理。这个“出口校验”和入站校验一样重要,很多团队只盯着入口,忽视了出站。
4. 策略引擎与沙箱的联动设计
4.1 一次技能调用的完整生命周期
把策略引擎和沙箱放在一起后,一次技能调用的完整生命周期大致如下:Agent收到用户请求后,先做意图识别和技能选择。如果即将调用的技能带有副作用,Agent侧会把动作请求上下文发给策略引擎。策略引擎裁决返回allow、deny或require_human_approval。allow则请求进入沙箱调度器;deny则请求直接终结;需要审批则进入待审批队列。
沙箱调度器根据技能名称选择对应的镜像和资源模板,从预热好的沙箱池里取一个空闲实例,注入会话上下文。沙箱实例内的技能代码开始执行,期间可以调用受限SDK。沙箱出口做输出校验后,结果返回到Agent层。一次会话里如果还要继续调用下一个技能,就重复上面的裁决流程。
这里要特别强调:决策和沙箱分配的绑定关系不能断。策略引擎给出的决策结果要带一个决策上下文ID,沙箱调度器执行时把这个ID也带上,审计系统通过这个ID把“决策-执行-输出”三个阶段日志串在一起。没有这一环,事后排查逃逸事件时根本对不上号。
4.2 审批流里的上下文保留
require_human_approval这个中间态,实际落地时比想象中复杂。核心问题在于:Agent的会话上下文是有状态的,如果请求进入审批队列,而Agent那边上下文很快失效,审批通过后再继续执行,技能可能拿不到必要的中间数据。
我们为此做了一个“上下文快照”机制。进入审批队列前,Agent侧把当前会话的关键上下文——用户历史消息、已执行的决策结果、技能参数、临时文件列表——序列化保存一份,带一个审批票据ID。审批通过后,Agent按票据ID恢复上下文,重新走一次策略裁决。这次因为有了审批票据,直接命中allow,再进入沙箱执行。
这个机制看起来多绕了一圈,但安全性上得到了保证。审批通过不意味着动作无限期有效,恢复后二次裁决时,如果环境里的敏感标记发生了变化,比如数据分级从低风险变成了高风险,仍然可以拦截住。这也是“审批只是过程,不是豁免”的原则。如果贪图方便,把审批通过直接当作后续所有动作的白名单,很快就会被滥用。
4.3 审计系统:出了事怎么还原现场
安全设计如果缺了审计,等于没有闭环。我们最后一次大事故排查,就是靠完整的审计日志才在几小时内定位到具体是哪一个技能的哪一个参数触发了违规。
审计日志的每条记录都包含五个关键字段:trace_id关联整个链路;决策ID把策略引擎一侧的日志和沙箱执行日志串联起来;策略版本快照记录当时生效的每一条策略的状态,因为规则会热更新,没有版本快照就说不清当时到底执行的是哪套规则;执行摘要记录沙箱内部的关键系统调用次数、网络连接目标、文件写入列表、超时时间;输入输出指纹对技能入参和返回结果做哈希,匹配上哈希就知道这次执行结果是否被篡改过。
还有一个经常被忽略的点:审计日志不仅要记录成功的命令,还要记录被拦截的请求。很多团队只关注“出了什么事”,不关心“差点出什么事”。被拦截的请求恰恰能反映策略的覆盖情况和攻击趋势,我们会定期分析拦截样本库,用来迭代策略规则和调整沙箱白名单。反馈闭环越强,安全系统的进化速度越快。
5. 落地过程中的坑与排查实录
5.1 策略过严导致技能大面积误杀
系统刚上线第一周,技能误杀率冲到35%,运营同事直接炸锅。排查后发现是策略配置过粗:只要技能参数列表包含“环境”字段,我们就要求人工审批,结果很多正常只读查询因为带了环境标识就走审批,体验极差。
这个教训很直接:策略字段的粒度必须和技能的实际行为对齐。只读技能不应该和高风险变更技能走同一套审批策略。我们把技能分成只读、受控操作、高风险变更三个风险等级,不同等级默认策略不同。只读技能只做敏感数据字段检查,高风险技能才强制审批。调整后误杀率降到2%以内,而阻断率基本没有变化。
5.2 沙箱内存限制过紧,技能起不来
某个进行PDF解析的技能在沙箱里反复启动失败,打开日志才发现是内存限制给少了。这个技能要加载一个比较重的解析库,启动时峰值内存比我们预估高了30%。我们最初按一般技能的两倍峰值设定限制,结果偏紧。
后来改成按技能分别标注资源画像:每次发布技能前,先在沙箱里跑一遍基准测试,记录CPU、内存、启动时间、网络请求数,再按基准的2.5倍设定实际上限。这个“按技能设资源”的做法虽然增加了上线工作量,但显著减少了运行时问题。至于内存不足导致的安全风险,则通过沙箱自身的隔离能力来控制,不会因为资源给多了就放开限制。
5.3 一个文件描述符引发的逃逸排查
某次安全扫描发现,一个沙箱进程的日志里出现了异常的文件路径访问。顺着审计日志一查,发现技能代码里有一行“读取配置文件”的逻辑,没有直接用SDK的受限文件接口,而是偷偷用了一种绕过方式拿到了宿主机上某个路径的文件描述符访问能力。
这个案例很值得复盘。问题根源不是沙箱技术没生效,而是技能代码本身绕过了我们规定的SDK接口。我们的整改措施是:沙箱内禁止加载原生的网络和文件操作模块,在SDK层面做符号级的白名单;同时对沙箱进程做定期扫描,发现调用白名单之外模块的直接隔离下线。这也是为什么我们后来特别强调“出口校验”——绕过SDK的代码就算拿到了数据,也要能被堵在沙箱内。
5.4 策略引擎性能瓶颈:正则解析拖垮QPS
有一阵子策略引擎的P99延迟从8毫秒直接飙升到300毫秒,定位后才发现是某个新加的策略用了一个特别复杂的正则表达式,参与评估的次数又多,直接把CPU打满了。
优化方案有两条:一是给规则加“粗筛索引”,比如按技能名先排除80%无关规则,再走精确正则匹配;二是取消对用户输入长文本做高开销正则,改为分词和抽样匹配,或者把正则拆成多个简单规则组合。经过这两项处理,P99延迟降回10毫秒以内。现在我们对新策略有一个硬性要求:上线前必须做性能自检,单条规则在最坏输入下的评估耗时不得超过100微秒。
5.5 调整策略时要盯三张图,别让规则静默失效
最后分享一个运维层面的经验。每次调整策略或沙箱参数,我会同时盯三张图:拦截量趋势、误杀率趋势、逃逸事件趋势。任何参数变更都要先在影子环境跑一轮,对比这三张图,确认没有新的风险波动再全量生效。安全这件事最怕“静默变化”——新的策略规则上线了,违规拦截量反而突然少了一大截,这时候大概率是规则出了漏洞,而不是攻击真的变少了。
我个人在这些项目里最深的一个体会是:AI代理的安全不是某个单一组件能搞定的,策略引擎和沙箱隔离必须配合起来,一个管“怎么进门”,一个管“进门后能碰到什么”。而两者之间那条审计链路,才是让安全问题可以被讨论、被复盘、被迭代的基础。后来者如果能少踩几个同样的坑,就不亏了。