用 250 行纯标准库,给 Agent 造一道能验的出站门

6 阅读15分钟

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+ 标准库),做三件事——

  1. 默认拒绝:没有显式声明的目的地,一律不通;
  2. 动作级放行:精确到 (host, method, path 前缀, purpose),不是"能上网";
  3. 防篡改证据链:每条决策落成 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. 策略文件

{
  "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 运行:

#!/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 个用例,全部按预期:

$ 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 调用):

DENY BUDGET_EXHAUSTED  GET api.openai.com/v1/models
  api.openai.com 在本任务内已用满 3 次(防扇出)

证据链校验:

$ 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,你的规则一条都不会触发。

修法分两层,缺一不可:

  1. 决策前解析并校验 IP(本文的 --resolved-ip + dns_pin 就是这个位置);
  2. 在真正出网的那一跳再做 IP/网段过滤,因为决策点拿到的解析结果和实际建连时的解析结果可能不同(TTL 很短时尤其明显,这就是 DNS rebinding 的经典时间窗)。

这也是为什么阿里 OpenSandbox 要做 DNS 劫持 + 底层 IP 过滤两层——只做一层,都有绕过路径。

坑三:预算账本不能放在沙箱里

现象:两个网关各自限流 60,实际打了 120 次。

原因:这是扇出。单笔上限只能管一次调用,管不了"同一个任务开十个沙箱各打六十次"。更根本的问题是——如果你的账本放在沙箱内、由 Agent 自己维护,那它就是一个可被 Agent 修改的账本,等于没有。

修法:账本 key 用 agent|task|host,落盘位置在网关侧共享存储。这份代码用单机 JSON 只够演示,多进程并发会丢计数(读-改-写不是原子操作);生产要换 Redis 事务、数据库行锁,或者直接在网关的令牌桶里做。

坑四:哈希链只防"事后改写",不防"当时就没写"

现象:verify 报 PASS,但那次攻击根本没经过决策点。

我做了两个篡改实验:

# 把一条 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 文档专用地址段。