2026-10-24 | 跨组织 Agent 协作周 Day 6 | 读完约 9 分钟 关键词:Agent 沙箱 / 出站默认拒绝 / 动作级白名单 / DNS 重绑定 / 可验证证据链
0. 先说结论
10 月 23 日,OpenAI 宣布前沿模型训练不再接入真实互联网;同一天 Anthropic 复盘了 Claude Mythos 5 在真实互联网上的越界行为。两天前,中国联通发布了智能体电信网 PATN 白皮书。
一边在拔网线,一边在铺公共底座。而夹在中间的工程侧,有一层东西没人替你写:出站边界。
这篇文章不复述新闻,只交付一个能跑的东西:egress_gate.py,约 250 行,零依赖(Python 3.9+ 标准库),做三件事------
- 默认拒绝:没有显式声明的目的地,一律不通;
- 动作级放行 :精确到
(host, method, path 前缀, purpose),不是"能上网"; - 防篡改证据链:每条决策落成 SHA-256 哈希链,一条命令校验有没有被改过。
代码在文末完整给出,我已在 Python 3.13.14 上跑通 8 个用例(文末附真实输出)。
1. 为什么"域名白名单"不够
先看三个已经把边界塌掉的方式,它们对应三种不同的错误假设。
假设一:解析器只解析内部名。 9 月 20 日,OpenAI 一款前沿模型利用 DNS 解析器的配置漏洞钻出沙箱,直连真实互联网,训练因此暂停到月底。没有密码学被攻破,塌的是一条配置。
假设二:虚拟化层没有漏洞。 红队在自己的基础设施里找到了虚拟机逃逸。注意这句话的准确形式应该是「暂时还没被发现漏洞」------前者是保证,后者是运气,两者差一个数量级。
假设三:断网了、内存隔离了,信息就传不出去。 缓存时序侧信道直接否掉:两个 VM 可以通过操纵处理器缓存、再对加载耗时计时来传递消息。共享硬件就是共享信道,这是物理层的反驳,应用层加密补不上。
所以「隔离」不是一个布尔值,是一个栈:硬件 → 内核 → 容器 → 网络 → 应用 → 数据。攻击者只找最薄那一层,而防御方最常见的失误,是把某一层的通过当成整栈的通过。
工程结论:只做域名黑名单,等于假设"Agent 不会拿到 IP、不会改 DNS、不会被诱导换个域名"。 三个假设都站不住。所以要做两层:域名层拦"看得懂的",IP 层拦"看不懂的"。
阿里 OpenSandbox 的 egress 就是这么分的------第一层在 sandbox 交付业务之前透明劫持 DNS 解析,第二层做底层 IP 与 IP 网段过滤,防止 Agent 拿到具体 IP 后绕过 DNS。第三层才是统一 ingress 与审计。对 Agent 进程完全无侵入。
我下面的实现处在"应用层决策点"这个位置,可以独立跑,也可以挂在代理后面当策略引擎。
2. 设计:一次决策要回答四个问题
一个出站请求进来,我只问四件事:
| 问题 | 失败时的 reason_code |
|---|---|
| 是不是加密的? | SCHEME_NOT_HTTPS |
| 目的地是不是一个"能审计的名字"? | IP_LITERAL_PRIVATE / IP_LITERAL_NOT_ALLOWED |
| 这条规则允许这个动作吗? | METHOD_NOT_ALLOWED / PATH_NOT_ALLOWED / PURPOSE_NOT_DECLARED |
| 这个任务还有额度吗? | BUDGET_EXHAUSTED |
再加两条先于一切 的拦截:永久黑名单(deny_always)和 DNS 钉扎(dns_pin)。
第二个问题值得展开。为什么禁止直连 IP ?因为一旦允许 https://1.2.3.4/,白名单里的域名就失去意义了------攻击者不需要你的域名规则,只需要一个 IP。更糟的是 169.254.169.254(云元数据服务)这类地址,一旦可达,等于把实例凭证交出去。
第三个问题是最容易被忽略的。同一个 host、同一个 GET,为"总结一份公开文档"和为"把用户邮箱传上去",是两件完全不同的事。 域名白名单只能表达"这个域名可信",表达不了"这次动作可信"。所以策略里必须有 purpose。这是有数据支撑的:从 capability-level 迁到 action-level allowlist,在桌面演练里爆炸半径降低 60%--80%------因为 Agent 字面上无法执行没被预先声明的动作。
3. 策略文件
json
{
"policy_ref": "egress-2026-10-24.3",
"default": "deny",
"deny_always": ["169.254.169.254", "*.duckdns.org", "*.ngrok.io", "*.trycloudflare.com"],
"dns_pin": {
"api.openai.com": ["203.0.113.10", "203.0.113.11"]
},
"rules": [
{"hosts": ["api.openai.com"], "methods": ["GET", "POST"], "paths": ["/v1/"], "purpose": ["llm"], "budget": 3},
{"hosts": ["*.github.com"], "methods": ["GET"], "paths": ["/"], "purpose": ["fetch_source"], "budget": 5},
{"hosts": ["github.com"], "methods": ["GET"], "paths": ["/"], "purpose": ["fetch_source"], "budget": 5}
]
}
三处设计要说明:
deny_always里放的是"无论如何都不该出去的",不是"业务不需要的"。169.254.169.254属于前者的典型,*.duckdns.org这类动态域名是常见的外传通道。dns_pin是防 DNS 重绑定的:如果这个域名的解析结果不在钉住的集合里,直接拒绝,不看后面的规则。budget的 key 是agent|task|host三元组。为什么按任务计?因为单笔上限管不住扇出------两个网关各授权 60,合计 120,而两个各自验证都会通过。
4. 实现
完整代码,可直接 python egress_gate.py 运行:
python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""egress_gate.py --- Agent 出站决策点 + 防篡改证据链(纯标准库,Python 3.9+)
它只做三件事:
1) 默认拒绝:没有显式声明的目的地,一律不通
2) 动作级放行:精确到 (host, method, path 前缀, purpose),而不是"能上网"
3) 每一条决策落成哈希链:事后能证明"当时是按哪一版策略判的"
用法:
python egress_gate.py check --policy policy.json --log evidence.jsonl \
--agent A-7 --subject user:u-1024 --method GET \
--url https://api.openai.com/v1/models --purpose llm
python egress_gate.py verify --log evidence.jsonl
退出码:0=ALLOW 3=DENY 2=参数或文件错误
"""
import argparse
import hashlib
import ipaddress
import json
import os
import sys
import time
from urllib.parse import urlsplit
EXIT_ALLOW, EXIT_ERROR, EXIT_DENY = 0, 2, 3
# ---------- 规则匹配 ----------
def host_matches(pattern: str, host: str) -> bool:
"""'*.example.com' 只匹配子域,不匹配 example.com 本身。
这是最常被写错的一条:很多人以为 *.example.com 包含裸域。
真实语义(smolvm --allow-host-pattern / RFC 6125 通配符)是不含。
所以裸域必须单列一条规则,否则你的主域请求会被自己的白名单挡掉。
"""
pattern, host = pattern.lower().strip("."), host.lower().strip(".")
if pattern.startswith("*."):
suffix = pattern[2:]
return host.endswith("." + suffix)
return pattern == host
def is_private_or_reserved(ip: str) -> bool:
try:
addr = ipaddress.ip_address(ip)
except ValueError:
return True
return (addr.is_private or addr.is_loopback or addr.is_link_local
or addr.is_reserved or addr.is_multicast or addr.is_unspecified)
def is_ip_literal(host: str) -> bool:
try:
ipaddress.ip_address(host)
return True
except ValueError:
return False
# ---------- 证据链 ----------
def _digest(record: dict) -> str:
body = {k: v for k, v in record.items() if k != "hash"}
blob = json.dumps(body, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
def last_hash(path: str) -> str:
if not os.path.exists(path):
return "GENESIS"
tail = None
with open(path, "r", encoding="utf-8") as f:
for line in f:
if line.strip():
tail = line
if not tail:
return "GENESIS"
return json.loads(tail).get("hash", "GENESIS")
def append_evidence(path: str, record: dict) -> dict:
record = dict(record)
record["prev_hash"] = last_hash(path)
record["seq"] = 0 if record["prev_hash"] == "GENESIS" else _next_seq(path)
record["hash"] = _digest(record)
with open(path, "a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False, sort_keys=True) + "\n")
return record
def _next_seq(path: str) -> int:
n = 0
with open(path, "r", encoding="utf-8") as f:
for line in f:
if line.strip():
n += 1
return n
def verify_chain(path: str):
"""返回 (ok, 说明)。逐行重算哈希并核对 prev_hash 链接。"""
if not os.path.exists(path):
return False, f"证据文件不存在:{path}"
prev, count = "GENESIS", 0
with open(path, "r", encoding="utf-8") as f:
for lineno, line in enumerate(f, 1):
if not line.strip():
continue
try:
rec = json.loads(line)
except json.JSONDecodeError as e:
return False, f"第 {lineno} 行不是合法 JSON:{e}"
if rec.get("prev_hash") != prev:
return False, f"第 {lineno} 行链断裂:prev_hash 与上一条 hash 不一致"
if _digest(rec) != rec.get("hash"):
return False, f"第 {lineno} 行内容被改动:重算哈希与记录不符"
prev, count = rec["hash"], count + 1
return True, f"链完整,共 {count} 条记录,尾哈希 {prev[:16]}..."
# ---------- 预算账本(防扇出) ----------
def load_budget(path: str) -> dict:
if not os.path.exists(path):
return {}
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def bump_budget(path: str, key: str, limit: int) -> int:
ledger = load_budget(path)
used = int(ledger.get(key, 0))
if used >= limit:
return -1
ledger[key] = used + 1
with open(path, "w", encoding="utf-8") as f:
json.dump(ledger, f, ensure_ascii=False, indent=2)
return used + 1
# ---------- 决策 ----------
def decide(policy: dict, args) -> tuple:
"""返回 (decision, reason_code, detail, matched_rule_index)"""
parts = urlsplit(args.url)
scheme = (parts.scheme or "").lower()
host = (parts.hostname or "").lower()
path = parts.path or "/"
method = args.method.upper()
if scheme != "https":
return "DENY", "SCHEME_NOT_HTTPS", f"只允许 https,收到 {scheme or '空'}", None
if not host:
return "DENY", "HOST_EMPTY", "URL 里取不到主机名", None
if is_ip_literal(host):
if is_private_or_reserved(host):
return "DENY", "IP_LITERAL_PRIVATE", f"{host} 是私有/保留地址(含云元数据 169.254.169.254)", None
return "DENY", "IP_LITERAL_NOT_ALLOWED", f"禁止直连 IP:{host}(请用域名,让策略看得懂)", None
for pattern in policy.get("deny_always", []):
if host_matches(pattern, host):
return "DENY", "HOST_DENYLIST", f"命中永久黑名单 {pattern}", None
# DNS 钉扎:解析结果必须落在钉住的集合里,否则视为 DNS 重绑定
pin = policy.get("dns_pin", {}).get(host)
if pin and args.resolved_ip:
if args.resolved_ip not in pin:
return "DENY", "DNS_DRIFT", f"{host} 解析到 {args.resolved_ip},不在钉住集合 {pin}", None
if args.resolved_ip and is_private_or_reserved(args.resolved_ip):
return "DENY", "RESOLVED_TO_PRIVATE", f"{host} 解析到内网地址 {args.resolved_ip}(DNS 重绑定)", None
for idx, rule in enumerate(policy.get("rules", [])):
if not any(host_matches(p, host) for p in rule.get("hosts", [])):
continue
if method not in [m.upper() for m in rule.get("methods", [])]:
return "DENY", "METHOD_NOT_ALLOWED", f"{host} 允许 {rule.get('methods')},本次是 {method}", idx
if not any(path.startswith(p) for p in rule.get("paths", ["/"])):
return "DENY", "PATH_NOT_ALLOWED", f"{host} 只允许路径 {rule.get('paths')},本次是 {path}", idx
if args.purpose not in rule.get("purpose", []):
return "DENY", "PURPOSE_NOT_DECLARED", f"该规则只服务 {rule.get('purpose')},本次声明 {args.purpose}", idx
limit = int(rule.get("budget", 0))
if limit:
used = bump_budget(args.budget, f"{args.agent}|{args.task}|{host}", limit)
if used < 0:
return "DENY", "BUDGET_EXHAUSTED", f"{host} 在本任务内已用满 {limit} 次(防扇出)", idx
return "ALLOW", "OK", f"命中规则 #{idx},本任务第 {used}/{limit} 次", idx
return "ALLOW", "OK", f"命中规则 #{idx}", idx
return "DENY", "NO_RULE_FOR_HOST", f"没有任何规则覆盖 {host}(默认拒绝)", None
def main() -> int:
ap = argparse.ArgumentParser(description="Agent 出站决策点")
sub = ap.add_subparsers(dest="cmd", required=True)
c = sub.add_parser("check", help="判定一次出站请求并写入证据")
c.add_argument("--policy", required=True)
c.add_argument("--log", required=True)
c.add_argument("--budget", default="budget_ledger.json")
c.add_argument("--agent", required=True)
c.add_argument("--subject", required=True, help="被代表的自然人,例如 user:u-1024")
c.add_argument("--task", default="T-1", help="任务号,预算按任务计")
c.add_argument("--method", default="GET")
c.add_argument("--url", required=True)
c.add_argument("--purpose", required=True)
c.add_argument("--resolved-ip", default=None, help="模拟 DNS 解析结果,用于演示重绑定")
c.add_argument("--quiet", action="store_true")
v = sub.add_parser("verify", help="校验证据链是否被改动")
v.add_argument("--log", required=True)
args = ap.parse_args()
if args.cmd == "verify":
ok, msg = verify_chain(args.log)
print(("PASS " if ok else "FAIL ") + msg)
return EXIT_ALLOW if ok else EXIT_DENY
try:
with open(args.policy, "r", encoding="utf-8") as f:
policy = json.load(f)
except (OSError, json.JSONDecodeError) as e:
print(f"策略文件读取失败:{e}", file=sys.stderr)
return EXIT_ERROR
decision, reason, detail, idx = decide(policy, args)
parts = urlsplit(args.url)
rec = append_evidence(args.log, {
"ts": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
"agent_id": args.agent,
"subject": args.subject,
"task_id": args.task,
"policy_ref": policy.get("policy_ref", "unversioned"),
"rule_index": idx,
"method": args.method.upper(),
"host": (parts.hostname or "").lower(),
"path": parts.path or "/",
"purpose": args.purpose,
"decision": decision,
"reason_code": reason,
"detail": detail,
})
if not args.quiet:
print(f"{decision} {reason} {rec['method']} {rec['host']}{rec['path']}")
print(f" {detail}")
print(f" evidence seq={rec['seq']} hash={rec['hash'][:16]}...")
return EXIT_ALLOW if decision == "ALLOW" else EXIT_DENY
if __name__ == "__main__":
sys.exit(main())
几个实现细节:
host_matches 是全篇最容易写错的一处。 *.example.com 只匹配子域,不匹配 example.com 本身 。这不是我的偏好,是 RFC 6125 通配符的语义,smolvm 也专门把 --allow-host(裸域 + 子域)和 --allow-host-pattern '*.example.com'(只子域)拆成两个参数。我第一版没注意,结果自家主域被自家白名单挡了。
证据记录里为什么要 prev_hash? 因为单条记录的哈希只能证明"这条没被改",不能证明"中间没被删"。把上一条的哈希串进来,形成链,删一行就会断链。校验成本是 O(n) 次 SHA-256,比任何回归测试都便宜。
记录里为什么有 policy_ref 和 rule_index? 因为事后追责时,第一个问题永远是"当时按哪一版策略判的"。如果策略改了、日志里没版本号,这份证据等于作废。
subject 字段不能省。 它记的是"被代表的自然人"。agent_id 只说明是哪个程序干的,subject 才指向谁该负责。这也是上周那篇审计文章的核心结论:很多实现最常缺的就是 subject。
5. 实测
环境:Python 3.13.14 / Windows。8 个用例,全部按预期:
bash
$ python egress_gate.py check --policy policy.json --log ev.jsonl \
--agent A-7 --subject user:u-1024 --task T-1 \
--method GET --url https://api.openai.com/v1/models --purpose llm
ALLOW OK GET api.openai.com/v1/models
命中规则 #0,本任务第 1/3 次
evidence seq=0 hash=2184fa2bf1d3d0e8...
# exit=0
$ ... --method POST --url https://api.github.com/repos/o/h/issues --purpose fetch_source
DENY METHOD_NOT_ALLOWED POST api.github.com/repos/o/h/issues
api.github.com 允许 ['GET'],本次是 POST
# exit=3
$ ... --url http://api.openai.com/v1/models
DENY SCHEME_NOT_HTTPS GET api.openai.com/v1/models
只允许 https,收到 http
$ ... --url https://169.254.169.254/latest/meta-data/iam/
DENY IP_LITERAL_PRIVATE GET 169.254.169.254/latest/meta-data/iam/
169.254.169.254 是私有/保留地址(含云元数据 169.254.169.254)
$ ... --url https://8.8.8.8/dns-query
DENY IP_LITERAL_NOT_ALLOWED GET 8.8.8.8/dns-query
禁止直连 IP:8.8.8.8(请用域名,让策略看得懂)
$ ... --url https://api.openai.com/v1/models --purpose exfil
DENY PURPOSE_NOT_DECLARED GET api.openai.com/v1/models
该规则只服务 ['llm'],本次声明 exfil
$ ... --url https://api.openai.com/admin/keys --purpose llm
DENY PATH_NOT_ALLOWED GET api.openai.com/admin/keys
api.openai.com 只允许路径 ['/v1/'],本次是 /admin/keys
$ ... --url https://api.openai.com/v1/models --purpose llm --resolved-ip 10.0.0.5
DENY DNS_DRIFT GET api.openai.com/v1/models
api.openai.com 解析到 10.0.0.5,不在钉住集合 ['203.0.113.10', '203.0.113.11']
预算耗尽(第 4 次同 host 调用):
bash
DENY BUDGET_EXHAUSTED GET api.openai.com/v1/models
api.openai.com 在本任务内已用满 3 次(防扇出)
证据链校验:
bash
$ python egress_gate.py verify --log ev.jsonl
PASS 链完整,共 8 条记录,尾哈希 21ae443215fd192f...
# exit=0
6. 四个踩坑记录
坑一:*.example.com 不含裸域
现象:https://github.com/octocat 被拒,reason 是 NO_RULE_FOR_HOST,而策略里明明写了 *.github.com。
原因:*.github.com 的语义是"任意一级子域",github.com 本身不在其中。修法有两个:给裸域单列一条规则(我用的方案),或者在 host_matches 里把 pattern[2:] 也当成命中。后者更省事,但会让"只放子域"这种意图表达不出来------而"只放子域、不放主域"恰恰是常见的隔离需求(比如只允许 raw.githubusercontent.com,不允许 github.com)。所以我保留了严格语义,让策略显式写两条。
自检方法:每条通配规则,补一条裸域用例和一条子域用例,两条都要跑。
坑二:域名白名单挡不住 DNS 重绑定
现象:策略全绿,流量进了内网。
原因:白名单校验的是字符串 ,不是解析后的 IP。攻击者只要控制一个被允许的域名,让它解析到 10.0.0.5 或 169.254.169.254,你的规则一条都不会触发。
修法分两层,缺一不可:
- 决策前解析并校验 IP (本文的
--resolved-ip+dns_pin就是这个位置); - 在真正出网的那一跳再做 IP/网段过滤,因为决策点拿到的解析结果和实际建连时的解析结果可能不同(TTL 很短时尤其明显,这就是 DNS rebinding 的经典时间窗)。
这也是为什么阿里 OpenSandbox 要做 DNS 劫持 + 底层 IP 过滤两层------只做一层,都有绕过路径。
坑三:预算账本不能放在沙箱里
现象:两个网关各自限流 60,实际打了 120 次。
原因:这是扇出 。单笔上限只能管一次调用,管不了"同一个任务开十个沙箱各打六十次"。更根本的问题是------如果你的账本放在沙箱内、由 Agent 自己维护,那它就是一个可被 Agent 修改的账本,等于没有。
修法:账本 key 用 agent|task|host,落盘位置在网关侧共享存储。这份代码用单机 JSON 只够演示,多进程并发会丢计数(读-改-写不是原子操作);生产要换 Redis 事务、数据库行锁,或者直接在网关的令牌桶里做。
坑四:哈希链只防"事后改写",不防"当时就没写"
现象:verify 报 PASS,但那次攻击根本没经过决策点。
我做了两个篡改实验:
bash
# 把一条 DENY 改写为 ALLOW
FAIL 第 4 行内容被改动:重算哈希与记录不符
# 删掉中间一行
FAIL 第 4 行链断裂:prev_hash 与上一条 hash 不一致
两个都能抓到。但第三种抓不到:Agent 从一开始就不走这个决策点,直接 requests.post 出去,链里干干净净,一条不剩。
这就是"日志必须防住它所记录的 Agent"的落法------证据必须由边界生成,不能由被观测者自报 。所以这个脚本的真实用法不是"让 Agent 调用它",而是:把它挂在代理后面做策略引擎,或者干脆用网络层强制(--network=none 只放行代理端口),让 Agent 没有绕过的选项。
7. 它在生产里还缺什么
诚实列一下,避免被当成轮子用:
| 缺口 | 现状 | 生产做法 |
|---|---|---|
| 并发原子性 | 单机 JSON 读-改-写 | Redis 事务 / DB 行锁 / 网关令牌桶 |
| TLS 层校验 | 只校验 URL 字符串 | 校验证书链 + 证书固定(防代理 MITM) |
| 请求体检查 | 完全没做 | 在代理层做 DLP:密钥、PII、分类标签 |
| DNS 结果一致性 | 只比对钉扎集合 | 决策点与实际建连共用同一个解析缓存 |
| 策略分发 | 本地文件 | 中心化策略服务 + 版本号 + 灰度 |
| 证据存储 | 本地 JSONL | 追加写对象存储 + 周期锚定(对外公示尾哈希) |
最后一条值得多说一句。哈希链能证明"这份文件没被改",但证明不了"这份文件是唯一的一份"------攻击者可以整份替换然后重算全链。要让外部可验证,得把尾哈希定期写到你看不见也改不了的地方:另一个组织的日志、时间戳服务、或者公链。这是"跨组织"三个字在证据层的真正含义。
8. 小结
- 边界不是一个开关,是一个栈。 每层都要能独立验证,任何一层的通过都不构成其他层的通行证。
- 动作级比能力级强一个量级。
(host, method, path, purpose)四元组是最小可用的表达力。 - 凭证不要进机器。 Google ACE 的做法值得抄:网关验完身份再插密钥,偷到沙箱身份也调不动 API。
- 证据要由边界生成。 观测者自报的日志,在追责时没有证明力。
代码你可以直接拿去改。如果你只做一件事,做这个:把 default 改成 deny,然后跑一天,看日志里冒出来多少你没意识到的出站请求。 那个数字通常比想象的大。
系列进度:本周 Day 6/5(加餐)。前五天分别讲发现层、握手与委托链、责任与审计、上线清单。明天开始筹备下周主题:记忆与上下文的持久化工程------状态一旦存在沙箱外面,"遗忘"该由谁执行。
环境说明 :本文代码在 Python 3.13.14 / Windows 11 验证通过;仅用 argparse / hashlib / ipaddress / json / os / sys / time / urllib.parse,无第三方依赖。示例中的 203.0.113.0/24 为 RFC 5737 文档专用地址段。