开源自托管 Agent 项目 Hermes Agent 怎么把网络出口关小:三块代码看清攻击面
本文基于 hermes-agent 仓库 commit 2d40494(2026-07-29)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/NousResearch/hermes-agent 最新代码与文档为准。
在这个项目里,「限制 Agent 上网」不是一个开关,而是四层互不替代的判定;其中三层写在 Python 里、明确被项目自己定性为启发式,只有最外面那层容器网络隔离落在进程之外。而按项目自己的说法,能算边界的只有操作系统层的隔离,网络分段只是它的一半——另一半是沙箱化的终端后端,两件事得一起配。 这里说的 Hermes 是 Nous Research 的开源自托管 Agent 项目 hermes-agent,不是同一家发布的 Hermes 开源模型系列,也不是其它同名商标或库——它是一个常驻在你机器上、开终端执行命令、接你的聊天软件账号、往磁盘写文件的程序。
站内已经有几篇相邻的文章:提示注入的通用防御思路讲方法论,pi 的安全边界和MCP 侧的安全边界讲的是另外的项目与协议层。这篇不重复那些结论,只做一件事:把这个具体项目的四层出口控制逐个翻开,看它把哪些判断写死在代码里、哪些留给你在配置文件和部署里补,以及哪一层失败时后果是”拦住”还是”放过”。
一、先看清「Agent 上网」的攻击面
Agent 发起的出站请求,来源比你想的杂。有你自己让它查的网页;有它读到某个网页后自己决定再去抓的链接;有它执行 shell 命令时顺手 curl 出去的一条;有插件里的适配器为了收发消息连的平台接口;还有 MCP 子进程自己建的连接。这几类走的代码路径完全不同,所以任何”我在工具层加了个 URL 检查”的说法都只覆盖其中一小块。
项目自己的 SECURITY.md 把这件事写在了信任模型的开头:唯一能对抗一个已经被诱导的模型的边界是操作系统层的隔离,进程内的审批门、输出脱敏、特征扫描、工具白名单,一个都不算真正的约束。这不是谦虚,是把话讲在前面——你如果指望 tools/ 下那几个检查函数替你挡住定向攻击,从一开始方向就错了。
下面这张表是四层的分工。仓库位置都是可以直接打开核对的:
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| URL 安全判定 | 解析 URL、解析 DNS、按 IP 类别决定放不放行 | tools/url_safety.py | 让它访问某个域名,尤其是内网域名时 |
| 连接时 SSRF 客户端 | 在 TCP connect 前再判一次,并直接拨已校验的 IP | tools/url_safety.py 里的 create_ssrf_safe_client / create_ssrf_safe_async_client | 自己写插件、要发 httpx 请求时 |
| 站点策略 | 读配置里的站点黑名单,按域名规则拦 | tools/website_policy.py | 想禁掉某几个站点,或多台机器共用一份名单时 |
| 威胁特征库 | 按三档范围扫注入与外传特征 | tools/threat_patterns.py | 工具结果、记忆写入、技能安装经过上下文组装时 |
| 不可信结果包裹 | 给几类工具的输出加上”这是数据不是指令”的标签 | agent/tool_dispatch_helpers.py | 每次网页抓取、搜索、浏览器、MCP 工具返回长文本时 |
| 容器出口隔离配方 | 两张 Docker 网络加代理白名单,把出口收窄 | docs/security/network-egress-isolation.md、docker-compose.yml | 把它部署到服务器上常驻时 |
| 边界行为的测试 | 把上面这些判定的预期结果固化下来 | tests/tools/test_url_safety.py、tests/tools/test_website_policy.py、tests/tools/test_threat_patterns.py | 你改了策略想确认没有意外放开时 |
顺带说一句这个仓库的体量,方便你判断”读多久能读完”:skills/ 下 14 个分类目录共 70 份 SKILL.md,optional-skills/ 21 个分类目录共 111 份,plugins/ 有 18 个顶层插件目录,optional-mcps/ 6 个,tests/ 里以 test_ 开头的文件 2499 个。安全相关的代码在其中占比很小,但位置集中,一个下午能看透。
二、第一道闸:URL 层的 SSRF 判定
tools/url_safety.py 的核心是 is_safe_url。它做的事按顺序是:解析 URL;协议不是 http/https 直接拒;主机名命中一组硬编码的内部主机名直接拒;然后才去看那个全局开关;再 getaddrinfo 解析出所有地址,逐个按 IP 类别判定。
要单独看的是那组”无论开关怎么设都拦”的地址。项目把云厂商的实例元数据端点当成不可协商的底线:
_ALWAYS_BLOCKED_IPS = frozenset({
ipaddress.ip_address("169.254.169.254"), # AWS/GCP/Azure/DO/Oracle metadata
ipaddress.ip_address("169.254.170.2"), # AWS ECS task metadata (task IAM creds)
ipaddress.ip_address("169.254.169.253"), # Azure IMDS wire server
ipaddress.ip_address("fd00:ec2::254"), # AWS metadata (IPv6)
ipaddress.ip_address("100.100.100.200"), # Alibaba Cloud metadata
...
})
这里有三个细节能看出写的人踩过坑。第一,IPv4 映射的 IPv6 形式(::ffff: 开头)被单独列了一遍,因为 Python 的 ipaddress 把它们当作与纯 IPv4 不同的对象,不额外列就会漏。第二,整个 169.254.0.0/16 链路本地段被整段拦掉,理由写在注释里:这个范围对 Agent 没有任何正当用途。第三,100.64.0.0/10 这个运营商级 NAT 段被显式补上了,因为标准库对它既不返回 is_private 也不返回 is_global,不补就是一个洞——用 Tailscale、WireGuard 或某些云内网的人会正好落在这个段里。
is_safe_url 的失败方向是拦。DNS 解析失败、解析出无法识别的地址、函数内部抛异常,一律返回不安全。但有一个例外要记住:当环境里配了代理变量(HTTP_PROXY、HTTPS_PROXY、ALL_PROXY 及其小写形式)且主机名不是字面 IP 时,DNS 解析失败会被当成”沙箱里本来就不允许直连 DNS”,直接放行交给代理去解析。这个取舍很实用,但它意味着一旦你配了代理,域名层的判定就转移给代理了——代理的白名单从这一刻起才是真正管事的那份。
单纯的预检还有个老问题:DNS 重绑定。检查时返回公网 IP,真正连接时返回内网 IP,这中间有时间窗。项目的处理办法不是加缓存,而是把判定挪到 TCP connect 那一刻:create_ssrf_safe_client 和它的异步版本会替换 httpx 传输层的网络后端,在建连前解析并校验,然后直接拨已通过校验的 IP,同时保留原主机名用于 Host 头、SNI 和证书校验。这一层还顺手拒掉了 Unix socket 连接,并把单次可尝试的地址数量设了上限。写插件要发请求的话,用这两个工厂函数比自己 httpx.Client() 强一档。
重定向是另一条绕过路径。模块里有个 redirect_target_from_response,注释里把原因写得很清楚:在 httpx 的响应钩子里 response.next_request 经常是空的,只依赖它的话,一个公网 URL 302 跳到元数据地址会被照跳不误;所以要先从 Location 头解析目标,拿不到再退回去看 next_request。这种细节不是设计出来的,是被打过一次才会写成这样。
还有一条容易被忽略的方向:URL 本身可能带着你的密钥往外走。模块里维护了一组明确是凭据的查询参数名,把 URL 交给第三方抓取或浏览器后端之前先查一遍;注释里特意解释了为什么不把 key、code、auth、sig 这类裸英文词放进去——它们在正常页面上太常见,放进去等于把普通浏览也拦了。这种”宁可窄一点也不制造噪音”的取向在这个文件里出现了好几次。
三、第二道闸:站点策略与内容特征扫描
tools/website_policy.py 是给你用的那一层:从 ~/.hermes/config.yaml 里读 security.website_blocklist,支持 enabled、domains 和 shared_files 三项,后者可以指向额外的名单文件、多机共用。规则会被归一化(去协议、去路径、去尾点、去 www. 前缀),匹配时既命中精确域名也命中子域,*. 开头的写法走通配匹配。解析结果在内存里缓存约半分钟,避免一次抓 50 个 URL 就把 YAML 读 51 遍。
关键差异在失败方向:这一层是放过。check_website_access 明确写了配置报错时记一条警告然后返回允许,注释里给的理由是”一个配置笔误不该让所有 web 工具瘫掉”。同样的取向也出现在 tools/browser_tool.py 的导入段——策略模块导入失败时降级成永远允许,而 URL 安全模块导入失败时降级成永远拒绝,元数据底线判定降级成永远拦。同一个文件里两种相反的降级方向,是有意为之:可用性让给策略层,安全性留给底线层。你要是把站点黑名单当作合规手段用,这个方向必须先想清楚。
tools/threat_patterns.py 管的是另一件事:进入上下文的内容里有没有攻击特征。它是 agent/prompt_builder.py、tools/memory_tool.py 和 agent/tool_dispatch_helpers.py 共用的一份特征库,按攻击类别而不是按调用方组织,每条特征带一个范围标记,三档从窄到宽是 all、context、strict。
分档的逻辑值得学:all 只放经典注入和外传,误报最少,任何文本都能扫;context 加上角色劫持、C2 风格的指令特征,用于上下文文件、记忆条目和工具结果——这些内容不是用户写的,检测面要宽;strict 再加上持久化后门、把会话内容往某个 URL 发之类的特征,只用在记忆写入和技能安装这两条路径上,因为那里误报了你能当场处理。宽检测不等于宽阻断,这是这份文件里最实用的一条判断。
文件开头那段”特征怎么写”的说明更该读。它要求新特征锚定在 C2 专有词汇或无歧义的攻击行为上,不要锚定在语气强硬的英文上——“you must”、“you are obligated to”这类话在正常的指令文档里满地都是。注释里还留了个反面案例:某个常见英文单词曾被当作 C2 框架名收进来,结果正常的人格设定文件被整份拦下,后来删掉了。特征之间的填充词用了有界的 (?:\w+\s+){0,8},既挡住”在关键词之间塞几个词”的绕过,又不给正则回溯留无界空间。扫描前会先在原始文本上查一遍不可见字符(零宽、方向覆盖那一批),再做 NFKC 归一化,好让全角字符折回 ASCII 再上正则;注释同时承认这挡不住跨字符集的形近字。整个扫描有 64 KB 的输入上限,因为它自认是顾问性质的守卫,不是检索工具。
第三层严格说不是”检查”,而是结构。agent/tool_dispatch_helpers.py 把几类结果可被外部操纵的工具输出包进 <untrusted_tool_result> 标签,明确告诉模型标签内是数据、不是指令,只有标签外的用户才能下指令。被这样处理的是网页抓取、搜索两个工具名,加上浏览器与 MCP 两个工具名前缀;短输出跳过,因为包裹的开销不值。内容里如果自带这个标签词,会被大小写不敏感地改写掉,防止外部内容提前闭合边界;而且不做”已经包过就不再包”的快速路径——注释说得很到底:那种判断是可伪造的,内容只要以开标签开头就能骗过它,重复包一次的代价小得多。至于扫描出来的特征,走的是内部风险元数据,不阻断也不改写正常结果,也不留原文副本。
四、第四道闸:容器网络出口隔离
docs/security/network-egress-isolation.md 是四层里唯一一层落在进程之外的操作手册。文档自己的定位也很克制:它是在既有执行边界之外再加一层,替不了沙箱化的终端后端。前提说得很明白:默认的 Compose 配置用 network_mode: host,Agent 进程有完全不受限的出站能力;这篇文档要做的是把它换成两张 Docker 网络。
架构就两句话。internal 网络设 internal: true,没有默认路由、不通外网,Agent 与面板跑在这里;egress 网络通外网,只挂真正需要连外部接口的服务。网关服务双挂两张网,因为它得从 Telegram、Slack 那些平台收消息再转给内网里的 Agent。这也解释了为什么隔离到这里就得停:网关必须有出口,它就是那条必要的缝。
更紧的做法是所有出站都过一个带白名单的 HTTP 代理。文档给的示例把代理地址通过 HTTP_PROXY、HTTPS_PROXY 注入,并用 NO_PROXY 把内部服务名排除;白名单那份是 squid 配置的样子:
# Only allow HTTPS CONNECT to these hosts
acl allowed_hosts dstdomain api.openai.com
acl allowed_hosts dstdomain api.anthropic.com
acl allowed_hosts dstdomain openrouter.ai
...
http_access allow CONNECT allowed_hosts
http_access deny all
注意两件事。第一,文档里的 override 文件和那份 squid 白名单都是要你自己新建的——仓库里现成只有默认的 Compose 文件,这篇文档是配方而不是开箱配置,照抄前得对着自己的模型服务商和消息平台改名单。第二,代理白名单和第二节说的那个”配了代理就把 DNS 交给代理”的分支正好接上:一旦走代理,域名层的实际决定权在这份 acl 上,它写得多松,出口就有多宽。
文档最后给了三条验证命令,都是从容器里 curl 一下看该失败的是否失败、该通的是否通。这一步别省。出口隔离最典型的失败模式不是配错,是以为配上了,而 override 文件因为文件名或加载顺序根本没生效。
五、边界与代价:它明确不管什么
这一层层加下来,放弃了什么,得摆清楚。
放弃了默认的开箱即用。 network_mode: host 之所以是默认值,是因为它最省事——不用管服务发现、不用管端口映射、消息平台的回调直接就通。换成两张网络之后,你要自己维护白名单,每接一个新的消息平台就得往名单里加一条接口域名,文档在限制一节里专门提了这点。
DNS 查询本身没被拦。 文档承认 internal 网络仍然能解析外部域名,除非你另外跑一个屏蔽外部查询的本地解析器;它给的判断是对多数威胁模型来说可以接受,因为单靠 DNS 解析外传不了多少东西。这个判断是否适用于你,取决于你机器上有什么。
它管的是容器的网络,不是命令的执行环境。 文档把这条写成了独立的一段:如果你用默认的本地终端后端,工具命令就在同一个容器里执行。网络分段和沙箱化的终端后端是两件事,要一起用。
进程内那三层,项目自己不当边界。 SECURITY.md 把审批门、输出脱敏、技能守卫都归到”进程内启发式”,理由是 shell 是图灵完备的,对 shell 字符串做黑名单结构上就不可能完备。它同时说明了插件与技能是加载进 Agent 进程、拿全量权限跑的,第三方插件与技能的边界是你安装前的人工审查——而审查技能意味着读它的 Python 代码和脚本,不是只读那份说明文件,因为技能在导入时就能执行任意 Python。
凭据过滤不是隔离。 项目会在传给 shell 子进程、MCP 子进程、定时任务脚本和代码执行子进程的环境里剥掉模型服务商密钥、网关令牌一类的变量,只放行你或技能显式声明的那些。文档明确说这只是降低随手外传的概率;任何跑在 Agent 进程内的东西,能读的和 Agent 一样多,包括内存里的凭据。
特征扫描不是拦截。 工具结果那一路只记内部风险标记、不阻断;阻断只发生在你能介入的路径上。指望它在你不看的时候替你拦住一次真实攻击,是误用。
至于模型服务商侧的那些限制,各家规则不同且会调整,以官方最新说明为准——这一层不在这个项目能替你决定的范围内。
六、上手与避坑清单
先决定终端后端,再谈网络。 会踩,是因为网络隔离的文档独立成篇,容易让人以为配完就完事了;而默认后端把命令跑在同一个容器里,网络分段拦不住同容器内的文件读写和进程操作。怎么避:先把执行位置定下来(默认本地、容器、还是云沙箱),再照这篇文档收窄出口,两件事一起验。相关的判断思路可以对照工作区隔离怎么做。
别把站点黑名单当强制手段。 会踩,是因为它的名字听起来像门禁,实际失败方向是放过:配置写坏了记一条日志就继续走。怎么避:把它当”减少误触”的便利设施,真正要禁的出口写进代理白名单,那份是 deny all 结尾的。
改完策略记得让缓存过期。 会踩,是因为解析结果在内存里缓存了约半分钟,你改完配置立刻测,很可能测的还是旧策略。怎么避:等一会儿再测,或者在测试代码里显式传入配置路径绕开缓存——模块里也有一个显式失效的函数可用。
私有 IP 放行开关是全局的,别顺手打开。 会踩,是因为你在公司网络或用了 VPN 时会遇到正常域名解析到私有段而被拦,最快的解法看起来就是把它打开。怎么避:先确认到底是哪一类地址被拦(私有段、运营商级 NAT 段、还是链路本地段),能用更窄的办法就别动全局开关;元数据端点在开关打开后依然被拦,这是代码里写死的,但除此之外的内网就都放开了。另外这个开关同时支持环境变量和配置文件两处、还有一处旧的兼容位置,排查时三处都要看,只改一处很容易以为没生效。
自己写插件发请求,别直接 new 一个 httpx 客户端。 会踩,是因为预检函数看着够用,而 DNS 重绑定和重定向这两条路径预检都盖不住。怎么避:用模块里那两个 SSRF 安全客户端工厂,它们把校验放在建连时;如果你的请求走代理,就把代理当成受信的出口边界来对待,这一点函数的文档字符串里写了。
加新特征别锚定语气。 会踩,是因为最容易想到的写法就是匹配”你必须""忽略之前的”这类命令式表达,而正常的指令文档里全是这种句子。怎么避:按文件开头那段说明来,锚定专有词汇或无歧义的攻击行为,填充词用有界写法,然后去测试目录里补一条正例和一条反例。方法论层面的取舍可以对照最小权限怎么设计。
验证要从容器里往外打。 会踩,是因为在宿主机上测什么都通,看不出隔离有没有生效。怎么避:照文档给的三条命令来——该失败的失败、内网服务该通的通、走代理访问白名单里的域名该成功,三条都对上才算配好。
收束
这四层的价值不在于每一层都强,而在于它们的失败方向被分清了:底线判定和 URL 层失败会拦,站点策略层失败会放过,特征扫描层根本不负责拦,只有容器网络那层落在进程之外——而它也只管网络,管不了命令在哪儿执行。你部署时真正要做的决定只有两个——命令在哪儿执行,出口白名单里放哪几个域名。这两个定了,剩下的都是加固。
接着读哪些文件,按这个顺序:SECURITY.md 第 2 节先把信任模型看完,它会纠正你对进程内检查的期待;然后 tools/url_safety.py 从上往下读注释,那些注释基本就是一份 SSRF 踩坑记录;再看 docs/security/network-egress-isolation.md 的限制一节,决定自己接不接受那几条取舍;最后翻 tests/tools/ 下对应的三个测试文件,看预期行为被固化成了什么样——比读实现快,也比读实现可靠。
本篇属于一个把开源常驻自托管 Agent 项目 Hermes Agent逐层拆开讲的系列,整体地图见 开源自托管 Agent 项目 Hermes Agent 是什么;沿着这条线往下,还可以看 开源自托管 Agent 项目 Hermes Agent 如何把密钥来源做成可插拔并划定作用域 和 开源自托管 Agent 项目 Hermes Agent 写盘前的四道关。