
1. 为什么给 Agent 上护栏从一次线上事故说起先说个真实经历。上个月我负责的一个 Agent 项目差点出大问题一个做数据分析的智能体在被用户诱导后尝试读取了服务器上的/etc/shadow文件好在当时文件访问层做了目录白名单请求被直接拦截了否则后果还真不好说。更早之前我还见过同事的项目因为 Agent 发起了一个内网请求把云厂商的元数据服务地址给打穿了拿到了临时密钥。这两件事让我彻底明白一个道理大模型的推理能力越强它能调用的工具越多权限失控的风险就越大。很多刚接触 Agent 开发的朋友会把注意力全放在提示词工程、模型选型、RAG 检索效果上但安全护栏这件事往往是上了生产环境之后才意识到有多重要。其实道理很简单传统的 Web 应用攻击面是固定的接口而 Agent 不一样它拿着你的工具清单、文件访问权限、网络请求能力按照模型自己“理解”的方式去执行任务。模型一旦被诱导、被注入恶意指令或者干脆就是理解偏差它就可能做出超出预期的事情来。这篇文章我就拿实际项目里的三个核心安全模块来拆解文件三级访问控制、SSRF 拦截、带校验位的响应脱敏。它们分别解决的是Agent 读文件、发请求、回传结果这三个最基础也最容易出问题的环节。这套方案在我参与过的多个 Agent 项目中都已经落地验证过希望能给正在做智能体开发、尤其是把 Agent 接进内部系统或私有数据场景的朋友一些参考。2. 文件三级访问别让 Agent 在服务器上“裸奔”2.1 三级模型的设计思路为什么不能只靠提示词约束文件访问这块最容易犯的错就是图省事直接在提示词里写一句“请勿访问敏感文件”。我见过很多项目组这么干结果就是模型根本没把这句话当回事——它只是概率上“倾向于”遵守提示词而不是“必须”遵守。一旦遇到精心构造的提示词注入或者文件路径是间接拼接出来的提示词约束立刻失效。所以文件访问控制必须从应用层强制实施而不是寄希望于模型的自觉。我这里说的“三级”不是一个抽象概念而是三个可拆解的独立层次第一级目录白名单Agent 只能读取预设目录下的文件路径统一做规范化处理。第二级能力拆分不同的 Agent 任务绑定不同的文件权限比如“只读”和“可写”分开。第三级内容安全扫描即使文件路径合法也要对文件内容本身做敏感信息检测防止被“曲线读取”。这三级之间是递进关系每一级都负责解决上一级漏掉的问题。只做第一级能防住目录穿越但防不住白名单目录内本身就有敏感文件只做前两级能限制路径和操作类型但防不住 Agent 读取一个包含大量身份证号、密钥的日志文件然后泄露出去。三级全上才能形成一个相对完整的闭环。2.2 核心实现路径规范化与目录穿越防护先看第一级的实现。目录穿越是文件访问里最常见的攻击方式攻击者构造../../etc/passwd这种路径利用 Agent 的路径拼接逻辑往里渗透。防护的思路很简单任何用户输入或模型生成的路径都必须经过一个“净化”步骤再进行访问。净化后的路径还必须验证它是否真的位于白名单根目录之内。import os from pathlib import Path ALLOWED_ROOT Path(/data/agent_workspace).resolve() def safe_resolve(user_path: str) - Path: # 禁止绝对路径和符号链接绕过 raw Path(user_path) if raw.is_absolute(): raise PermissionError(absolute path is not allowed) # 拼接后做 resolve能处理 .. 和符号链接 candidate (ALLOWED_ROOT / raw).resolve() # 关键校验候选路径必须仍在根目录内 if not candidate.is_relative_to(ALLOWED_ROOT): raise PermissionError(path escapes workspace root) return candidate这段代码里有几个细节值得注意。第一禁止绝对路径所有路径都基于白名单根目录拼接从源头杜绝了直接指向系统目录的可能性。第二用resolve()处理..和符号链接因为单纯用normpath只能处理字符串层面的..但处理不了符号链接指向外部目录的情况。第三is_relative_to校验是二次确认即使前面的步骤有遗漏这一步也能兜底。但到这里还不够。我在项目里还会加一个“最小目录”策略不是每个 Agent 都能访问整个工作区而是在启动时给当前任务分配一个独立的子目录。比如任务 A 只处理/data/agent_workspace/task_a/那它就绝对不能访问task_b目录下的内容。这个限制靠的是进程级隔离不是靠同一进程里的路径判断具体后面讲。2.3 能力拆分与权限最小化读和写绝不共用一把钥匙第二级是最容易被忽略的。很多实现里只要 Agent 能访问某个目录就同时给了读、写、删除的权限。这其实是很大的隐患——大模型在规划任务时偶尔会“灵光一闪”去修改甚至删除它不该动的文件。我的做法是把文件操作拆分成三类能力只读、读写、执行。每个能力在注册工具时单独声明工具层做权限校验。举个例子class FileToolPermission(Enum): READ_ONLY read_only READ_WRITE read_write EXECUTE execute READ_ONLY_TOOLS [read_file, list_dir, search_file]在 Agent 的工具注册表里读操作和写操作是分开注册的任务创建时根据业务需求注入不同的权限配置。同一份代码通过权限配置就能限制 Agent 能调用哪些工具。这种设计不仅仅是“技术洁癖”而是为后续做审计打基础——每次文件操作都带有任务ID、Agent ID、操作类型、时间戳出了问题能清晰追溯。这里还想多说一句权限拆分一定要在框架层面做不要写在工具函数内部。原因很简单工具函数内部判断容易遗漏而且后续加新工具时很可能忘了补权限检查。框架层面统一拦截新工具只要注册到对应权限分组里就能自动获得一致的校验逻辑。3. SSRF 拦截Agent 的每次都请求都要“过安检”3.1 为什么 Agent 场景下的 SSRF 比传统 Web 更危险SSRF服务端请求伪造不是新概念传统 Web 里就有。但 Agent 场景下它的危害被放大了很多倍。传统 Web 的 SSRF通常是某个参数能被攻击者控制诱导服务器去请求内网地址。Agent 里则完全不一样——模型自己会构造 URL。也就是说你无法通过简单的“过滤用户输入”来防住它因为请求的目标地址是模型根据对话上下文“推导”出来的可能上一秒还在正常查百科下一秒就根据恶意网页内容生成了访问云元数据服务的请求。还有一个因素加剧了风险Agent 项目通常部署在云服务器上而云服务器的内网环境里全是敏感服务——Redis、数据库、对象存储、其他微服务、云厂商的元数据接口。这些服务很多没有做完善的访问控制只是依赖“内网不可达”来隐形。Agent 一旦能发起任意网络请求相当于把一个攻击跳板直接插到了内网核心区。我在一个客户项目里遇到过真实案例Agent 在浏览一个被篡改的网页时页面里的 JavaScript 嵌入了诱导指令让模型去请求http://169.254.169.254/latest/meta-data/iam/security-credentials/。这是云厂商元数据服务的地址正常情况下外部访问不到但 Agent 在服务器上发请求时这就是一个普通的 HTTP 请求。如果不是拦截层拦住了云主机的临时密钥就泄露了。3.2 拦截策略四层过滤层层设卡我的 SSRF 拦截方案分四层每一层解决一类绕过方式第一层协议白名单。只允许 HTTP/HTTPS 协议其他协议file://、gopher://、dict://、ftp://直接拒绝。这层是为了防协议走私——历史上出现过用 gopher 协议向内网 Redis 发命令的攻击方式ctfshow 的 ssrf 打 redis 题基本就是这个套路。第二层域名/URL 解析校验。对 URL 做正规化解析提取 host、端口、路径等要素。这一步看起来简单但坑很多。Python 的urlparse对某些畸形 URL 的解析结果很可能出乎意料比如http://evil.com192.168.1.1这种实际访问的是 192.168.1.1但解析出来的 host 是evil.com。所以不能只信解析库的第一次结果必须模拟真实请求时的连接目标。第三层IP 地址校验。域名解析后的 IP 必须经过检查不能是内网地址、环回地址、链路本地地址、云元数据服务地址。这里要注意解析必须自己做 DNS 解析不能依赖系统解析后再去拿 IP——那样容易出现 TOCTOUtime-of-check to time-of-use竞态问题。第四层DNS 重绑定防护。这是最容易被忽视的。攻击者可以让一个域名先解析到公网 IP通过校验后在请求阶段重新解析到内网 IP造成防护绕过。解决思路是强制使用“解析结果一致”的请求流程先解析域名确认 IP 安全再直接连接这个 IP而不是让系统重新解析域名。import socket import ipaddress from urllib.parse import urlparse BLOCKED_RANGES [ 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8, 169.254.0.0/16, 0.0.0.0/8, 100.64.0.0/10, 192.0.0.0/24 ] def resolve_and_check(hostname: str) - str: # 获取所有解析结果不是只看第一个 infos socket.getaddrinfo(hostname, None) ip_set set() for info in infos: ip info[4][0] ip_set.add(ip) addr ipaddress.ip_address(ip.split(%)[0]) for block in BLOCKED_RANGES: if addr in ipaddress.ip_network(block): raise PermissionError(fblocked internal IP: {ip}) return ip_set注意这里我用了getaddrinfo而不是gethostbyname因为前者能拿到 IPv6 结果。IPv6 也是绕过重灾区比如::1就是本机回环fe80::开头的是链路本地地址如果你只过滤了 IPv4 的内网段把http://[::1]:8080/这种请求放过去照样能打到本机服务。3.3 实际落地一个带缓存的安全请求封装光有校验函数还不够还要把校验嵌入到实际的 HTTP 请求流程里。我封装了一个安全请求模块核心思路是“手动控制连接过程”而不是把 URL 直接丢给 requests 库。import requests from requests.adapters import HTTPAdapter class SafeRequest: def __init__(self): self.session requests.Session() # 绑定到指定 IP 进行请求避免二次 DNS 解析 self.session.mount(https://, SafeHTTPAdapter()) self.session.mount(http://, SafeHTTPAdapter()) def get(self, url: str, timeout: int 5): parsed urlparse(url) if parsed.scheme not in (http, https): raise PermissionError(protocol not allowed) safe_ips resolve_and_check(parsed.hostname) # 随机选择一个安全 IP 发起请求伪造 Host 头维持原域名 target_ip safe_ips.pop() # 这里用底层 socket 连接 target_ip但 HTTP 请求头里的 Host 仍是原域名 response self._request_with_ip(url, target_ip, timeout) return response核心技巧在于校验阶段解析出的安全 IP在真正发起请求时直接复用而不是让系统再去查一遍 DNS。_request_with_ip的实现是通过自定义HTTPAdapter的get_connection方法将连接池的地址绑定到校验后的 IP 上。这样既保证了 HTTPS 证书校验时 SNI 和 Host 正确又避免了重绑定绕过。这层封装在项目里已经稳定运行了大半年拦截过的恶意请求不下几百次。有个比较隐蔽的坑是如果 Agent 需要访问的某个正规域名本身解析到了 CDN而 CDN 节点恰好覆盖了内网 IP极少见但存在会被误拦。解决方法是维护一个可信域名白名单对这些域名跳过内网 IP 校验但协议白名单和 URL 规范化仍然生效。4. 带校验位的响应脱敏既要遮得住更要防篡改4.1 为什么普通脱敏不够LLM 输出天然存在“被篡改”的可能Agent 执行任务后会把结果返回给用户或上游系统。很多敏感信息——身份证号、手机号、银行卡号、密钥、内部系统地址——就藏在这些响应里。常规做法是做脱敏把敏感字段替换成星号或假数据。但单独做脱敏有个问题脱敏后的结果无法验证完整性。打个比方一个 Agent 生成了包含用户手机号的列表脱敏层把手机号中间四位替换成****输出是138****1234。如果这个输出在传输过程中被中间人篡改成138****5678接收方根本察觉不到。传统的 Web 接口有签名机制来防篡改但 Agent 的输出是模型动态生成的没法用静态密钥对“内容”签名。这就是为什么需要“带校验位的响应脱敏”——在脱敏的同时生成一个基于原始数据的校验位接收方通过比对校验位来判断数据是否被篡改。4.2 字段级脱敏从“一刀切”到“精准打击”脱敏粒度上我强烈建议做字段级不要做整段替换。整段替换的问题是信息熵损失太大——一份报告里 90% 的内容都没问题只是含有一个手机号你就把整段内容全部打码用户没法看了Agent 的可用性大打折扣。字段级脱敏需要用正则或 NER 模型先识别敏感字段再按字段类型做不同策略字段类型示例脱敏策略手机号13812345678保留前3后4138****5678身份证号110101199001011234保留前6后4110101********1234IP地址10.23.45.67全量替换***.***.***.***密钥/Tokensk-abc123...保留前4后4中间全遮sk-a****xyz邮箱userexample.com保留首字母和域名u***example.com脱敏本身不复杂复杂的是确定“哪些字段算敏感”。我踩过不少坑最典型的是正则漏报业务方新接了一个数据源里面有个字段叫employee_code格式是EMP20240101001一开始没进敏感字段名单结果直接裸奔出去了。后来上了 NER 模型辅助识别 人工复核的流程准确率才上来。4.3 校验位Verification Fingerprint的设计与实现校验位的设计我的方案用 HMAC。核心逻辑是脱敏前的完整数据内容加上一个防篡改的“任务上下文”比如任务 ID 和用户 ID一起做 HMAC-SHA256 签名输出固定长度的校验码。接收方拿到脱敏数据和校验码后只需重新计算一遍——但因为接收方没有原始数据它不能直接校验“原始数据的内容”它校验的是“脱敏数据与原始数据是否一致”。这里需要引入一个巧妙的设计脱敏后的每个敏感字段使用一个确定性替换。也就是说同一个原始值在同一个任务内只对应同一个脱敏值比如13812345678永远映射为138****5678而不是随机替换。这样接收方虽然不知道真实手机号但能校验“脱敏数据中所有字段的变换关系是否与原始数据被签名时一致”。更具体地说校验位不是对“脱敏后的字符串”做签名而是对“脱敏映射表”做签名import hashlib import hmac import json SECRET_KEY byour-hmac-secret def generate_redaction_signature(task_id: str, original_text: str, redaction_map: dict) - str: # redaction_map 保存的是 {原始值: 脱敏值} 的映射表 payload { task_id: task_id, original_hash: hashlib.sha256(original_text.encode()).hexdigest(), redactions: redaction_map } data json.dumps(payload, sort_keysTrue, ensure_asciiFalse).encode() return hmac.new(SECRET_KEY, data, hashlib.sha256).hexdigest()接收方验证时只需要校验两点第一redactions里的映射关系没有变更第二脱敏后的文本里的占位符和redactions里的占位符一一对应。这样一来即使中间人把138****1234改成138****5678接收方比对该字段的映射表时就会发现不一致校验失败。这个方案在生产环境有一个显著的好处Agent 的响应可以放心地经过日志系统、消息队列、第三方回调不怕被篡改也不需要在链路上额外加 HTTPS 证书——虽然 HTTPS 本身也要上但多一层纵深防御总归是好的。4.4 脱敏组件与校验位在 Agent 链路中的位置脱敏和校验位不是一个独立的服务而应该是 Agent 输出链路上的一个“网关层”。在我的项目里这个网关层处理的是所有工具返回的数据、以及最终给用户的响应。Agent 工具调用产生原始数据 - [数据脱敏网关] - 字段识别与脱敏 - 生成脱敏映射表 - 生成 HMAC 校验位 - 响应发送给调用方 / 用户 调用方 / 用户收到带校验位的响应 - 可选回调“数据完整性校验接口” - 输入脱敏数据 校验位 - 输出数据是否完整、未被篡改这里有一个经验性建议校验位不要直接放在响应的业务字段里而是放在响应头的自定义字段如X-Data-Fingerprint中。好处是业务方的 SDK 或下游系统无感不破坏原有的数据协议需要验证时再读取头字段即可。我在多个项目里都是这么做的下游系统升级成本几乎为零。5. 常见问题与排查技巧实录5.1 文件访问模块的典型坑符号链接与路径大小写文件这块我遇到最多的问题是符号链接绕过。一开始我用os.path.normpath做路径规范化当时觉得已经处理了..就很安全。后来测试组用一个软链接指向/etcnormpath根本识别不出来因为软链接的解析发生在操作系统层面不在字符串处理层面。换成Path.resolve()之后才真正解决。还有一个容易被忽略的细节是路径大小写。Linux 下大小写敏感通常没问题但一旦跑在 macOS 的默认文件系统上大小写就不敏感了。如果你的白名单目录是/Data/Agent而实际路径是/data/agent在 macOS 上能正常访问但一到 Linux 上就被拦截这类环境差异问题排查起来特别费时间。建议统一在启动时把白名单目录做一次大小写规范化并且在代码里写死小写。另一个坑是文件类型判断。不要只依赖扩展名做白名单——攻击者完全可以上传一个伪装成.txt的脚本文件。实现在读取后检查文件头magic bytes会更稳比如判断是否包含 ELF 头、PowerShell 脚本特征等。如果 Agent 的项目里允许上传附件这个检查是必要的。5.2 SSRF 拦截的常见漏网之鱼SSRF 的绕过手段太多我踩过的坑够写一篇长文了。挑几个典型的说URL 编码绕过。攻击者把http://127.0.0.1编码成http://127.0.0.%31有些解析器在解析时能正常解析成内网地址但你的校验代码如果只检查了原始字符串就会放行。解决办法是解析 URL 时用标准库的urlparse之后还要对 hostname 做一次 unquote 处理并且用ipaddress库重新解析 IP。IPv6 省略写法。http://[::ffff:127.0.0.1]这其实是 IPv4 映射的 IPv6 地址很多过滤逻辑只查 IPv4 地址段直接把这种漏过去了。处理方式是把解析结果统一用ipaddress.ip_address()转成对象它会自动识别映射地址的底层 IP。重定向跟随。即使你在第一次请求时校验了目标 IP如果请求过程中跟随了 302 重定向重定向目标可能是一个内网地址。requests 库默认会跟随重定向所以要在请求前关闭自动重定向手动逐跳校验response session.get(url, allow_redirectsFalse) while response.status_code in (301, 302, 303, 307, 308): next_url response.headers.get(Location) if not next_url: break # 对 next_url 重新做完整的安全校验 validate_url(next_url) response session.get(next_url, allow_redirectsFalse)这个坑是真实发生过的——某个 Agent 通过浏览器的window.location被诱导跳转第一次请求的域名是安全的但重定向到了内网管理后台。DNS 解析缓存。操作系统自带 DNS 缓存可能导致解析结果和实际连接不一致。前面说的“手动绑定 IP 发起请求”方案能规避这个问题但如果你用的是系统库直接请求缓存可能让你在第二次请求时连到不同的 IP。这也是为什么我坚持在请求层做连接控制而不是只在校验层做判断。5.3 脱敏过度导致 Agent 可用性下降怎么办脱敏层的最大矛盾是安全和可用性的平衡。我在最初版本里把脱敏策略设得很激进所有疑似手机号的数字串全部打码结果 Agent 处理一份包含大量订单号的报表时订单号也被误伤成了星号下游没法关联业务数据。后面改进了两个点。第一引入“上下文感知”的脱敏策略不是所有数字串都按手机号处理需要结合周围文本特征判断。比如文本里明确出现“手机号”“联系电话”这种提示词时才对后面的数字串执行手机号脱敏。第二增加了“脱敏白名单”机制业务方显式声明某些字段或数据类别不需要脱敏比如测试环境的模拟数据这样既保留可用性又保证生产数据的安全。另外一个通过实践验证有效的办法脱敏前先做一次“数据分类”的日志记录记录哪些字段被识别为敏感、命中了哪种规则。这样当业务方反馈“数据被误伤”时你能快速定位到是哪个规则过于激进而不是大海捞针。5.4 校验位验证失败时的排查路径校验位验证失败基本上意味着数据在传输或存储过程中被动过手脚但有时候也可能是自己的签名逻辑有 bug。排查时按这个顺序走第一步确认时间戳和任务 ID 是否一致。如果任务 ID 或上下文在链路中被替换HMAC 计算出来的结果必然不一致。我见过的情况是下游系统在转发请求时不小心改写了任务 ID 字段。第二步确认脱敏映射表是否完整。如果脱敏映射表在传递过程中丢失了部分字段签名校验一定失败。这里有个性能优化点映射表可以做成增量传递但增量本身也要参与 HMAC 计算否则就存在拼接注入的风险。第三步确认字符编码。中文字符串的 UTF-8、GBK、Unicode 转义等差异会导致同样的内容计算出不同的 HMAC。我在跨语言系统中Python 生成校验位、Java 校验踩过这个坑最后约定所有参与签名的字符串统一用 UTF-8 编码做normalize(NFC)。第四步检查是否误用了非确定性脱敏。如果脱敏过程中用了随机数比如每次生成不同的假手机号那校验位就永远对不上。这就是为什么上文强调必须用确定性替换——同一个原始值在同一任务内只能映射到一个脱敏值。6. 写在最后的实操体会三个模块做完之后我最大的感受是Agent 安全不是一个“功能”而是一种“设计习惯”。你把文件访问、网络请求、响应输出都当作不可信数据来处理护栏自然就会一层一层长出来。在这套方案的迭代过程中我也意识到没有什么防护是绝对安全的。攻防本来就是一场永无止境的博弈——你会 DNS 重绑定攻击者就会想出 IPv6 转换绕过你做了 HMAC 校验位攻击者就会尝试从日志里反推脱敏映射关系。所以在工程落地时我一直遵循几条原则纵深防御而不是单点防护默认拒绝而不是默认放行做了就要可审计而不是黑盒运行。在 Agent 这个快速演进的领域里安全不该是滞后补位的一方。从一个普通工具调用到一次完整的数据回传每一个环节都值得用最严格的眼光审视一遍。希望这篇分享里的方案和踩坑记录能帮你在搭建 Agent 护栏时少走一些弯路。