先说清楚:为什么边界要放在容器和网络,而不是提示词里
agent 的权限来自它能执行的命令。在提示词里写「不要访问外网」是同义反复:它既不可验证,失败了也拦不住——你只能在事后从日志里发现。真正能挡住的是三层:进程跑在哪个容器里、容器有没有网络出口、出口允许连谁。前两层跟模型无关,第三层是唯一需要你持续维护规则的地方。
9 月 28 日英伟达发的 Open Agent Safety Platform 用的是同一个思路:开源软件 OpenShell 负责给 agent 划边界,独立的监控跑在另一颗芯片(BlueField-4)上,发现越界就在毫秒级隔离。守卫不放在 agent 自己身上,这正是它能起作用的原因。
第一步:先量出它现在能连什么
收口之前先有一份基线,否则你既不知道该放行谁,也无法证明收口有效。在 agent 实际运行的环境里跑一次真实任务,边跑边采两份数据:出站目的地和它解析过的域名。
# 出站连接(含已建立的)
ss -tnp | awk '{print $5}' | sort | uniq -c | sort -rn | head -30
# 它解析哪些域名(如果用了 systemd-resolved,日志在 journalctl -u systemd-resolved)
cat /etc/resolv.conf
# 有没有非 http(s) 的出站(这些往往是代理白名单管不到的)
ss -tunp | grep -v ESTAB | head -20把结果分成两类:一类是「不做这件事就完不成任务」——包管理源、代码托管、模型 API;另一类是「顺手访问」——搜索、文档站、随便点开的链接。第二类是你要收掉的部分,而且往往占大多数。
第二步:起一个默认没有出口的容器
--network none 的容器没有网卡,这是最硬的边界:agent 在里面能读能写能跑命令,就是连不出去。先用它确认边界确实生效,再加白名单,顺序不要颠倒——先开再收,很容易把「必须连的东西」和「习惯连的东西」混在一起放行。
docker run --rm -it --network none \
-v "$PWD:/work" -w /work alpine:3.20 sh -c \
"ip addr; wget -T 3 -O- https://example.com || echo BLOCKED"预期结果是只看到 lo(回环)一个接口,然后打印 BLOCKED。如果这里就通了,说明你加的不是 none 网络,或者容器用了 host 网络——先解决这个,后面的白名单才有意义。
第三步:只留一个出口——代理
接下来建一个内部网络:agent 容器和代理容器都在里面,但这条网络没有对外路由,所以 agent 唯一的出网方式就是那个代理。
# 内部网络:没有到外部的默认路由
docker network create --internal agent-net
# 代理容器要能出网,所以同时接外部网络
docker run -d --name egress-proxy \
--network agent-net \
-v "$PWD/squid.conf:/etc/squid/squid.conf:ro" \
ubuntu/squid:6.6代理的配置把「允许谁」写死。下面这份只放行 443 的 CONNECT,并且只对白名单域名放行——其余一律拒绝。注意只放 CONNECT 而不放全部端口,是为了挡住用非常规端口外传数据的路子。
# squid.conf
acl allowed_domains dstdomain .github.com .pypi.org .files.pythonhosted.org .registry.npmjs.org .anthropic.com
acl SSL_ports port 443
acl CONNECT method CONNECT
http_access deny CONNECT !SSL_ports
http_access allow CONNECT allowed_domains
http_access deny all代理容器本身要接上能出网的网络才能转发,所以启动命令里给它两个网络(agent-net 用于被 agent 访问,bridge 用于出网),agent 容器则只接 agent-net。
第四步:把白名单交给代理,而不是交给模型
agent 侧只写一个代理地址,不写任何域名规则——规则只有一份,放在代理的配置文件里。要加新域名,改的是这份文件、走一次代码评审。这一点很关键:规则一旦写进 agent 看得见的地方,它就有机会绕过;写在它够不到的容器里,它只能按要求做事。
docker run --rm -it --network agent-net \
-e HTTPS_PROXY=http://egress-proxy:3128 \
-e HTTP_PROXY=http://egress-proxy:3128 \
-e NO_PROXY=localhost,127.0.0.1 \
-v "$PWD:/work" -w /work node:22 sh常见的坑有两个:一是只设了大写环境变量,而某些工具只读小写的 http_proxy / https_proxy,两套都写上最省事;二是 NO_PROXY 没配,导致 agent 连本机服务也走代理,调试时会被绕晕。另外,命令行走代理但 DNS 仍可能被直接使用,所以不建议在这个网络里放行 53 端口。
第五步:加一条回归测试,证明拦截真的生效
白名单最常见的退化方式,是有人为了「让它先跑通」把最后那行 deny all 删掉。用一条断言把这件事钉死:允许的必须成功,不允许的必须失败。
#!/usr/bin/env bash
set -euo pipefail
# 1) 白名单内的域名应该通
code=$(docker exec agent sh -c \
'curl -sS -o /dev/null -w "%{http_code}" https://pypi.org/simple/')
[ "$code" = "200" ] || { echo "FAIL: 白名单域名返回 $code"; exit 1; }
# 2) 白名单外的域名必须被拒
if docker exec agent sh -c 'curl -sS --max-time 5 -o /dev/null https://example.com'; then
echo "FAIL: 非白名单域名被放行"; exit 1
fi
echo OK把它放进 CI 每天跑一次,或者挂在部署后检查里。断言失败基本只有两种原因:代理配置被改了,或者 agent 容器被换到了别的网络——这两种都值得立刻知道。
这套做法管不到的地方
它挡住的是「agent 自己往外连」,管不到三类问题。一是白名单内部的横向移动:它能访问的某个服务本身可能就是攻击面,白名单要做「是否真的需要」的复核,而不是一次加完就不动。二是权限过大的凭证:网出去了,但拿它调内部接口照样能改数据,所以出口白名单要和凭证最小化一起做,单独做一层都不够。三是数据从「允许的」那个域名出去——如果白名单里有搜索或模型 API,agent 仍然可以把信息放进请求里带出去,这种只能靠在入口侧做数据分级来防。
自检清单:容器默认无出口;只有代理一条通路;白名单写在配置文件里、改它需要评审;每天有一条断言证明非白名单域名一定失败;白名单里的每个域名都做过一次「是否真的需要」的复核。五条里缺哪条,边界就有一条缝。