多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

AI Agent 沙箱逃逸与权限边界设计实战

AI Agent 沙箱逃逸与权限边界设计实战 1. 从一次“越界”说起AI Agent 的权限边界到底意味着什么AI Agent 逃出沙箱这件事最近在开发者圈子里讨论得挺热。很多人第一反应是“是不是模型有了自我意识”但真正做过 Agent 开发的人都知道这跟意识没有半点关系本质上是权限边界设计出了问题。你给 Agent 一把钥匙它就会用这把钥匙去开门至于门后面是什么它并不关心它只关心任务能不能完成。我先把话说清楚这篇文章不聊科幻不聊“AI 觉醒”只聊工程。聊的是当你用 Claude Code、Codex CLI 这类命令行 Agent 工具或者自己基于 OpenAI API 搭一套 Agent 时沙箱是怎么被绕过的、权限边界应该怎么划、哪些坑是必须提前堵上的。如果你正在做 AI Agent 开发、正在给团队搭自动化流程、或者只是想让 Agent 帮你跑跑脚本这篇内容都值得你花时间看完。所谓“逃出沙箱”在工程语境下通常指这么几种情况Agent 执行了预期之外的命令、访问了沙箱外的文件系统、发起了未被授权的网络请求、或者通过工具链的漏洞拿到了宿主机权限。这些都不是玄学每一个都有具体的触发路径和对应的防御手段。下面我会一层一层拆开讲。2. 沙箱机制的核心原理与常见失效路径2.1 沙箱到底在防什么先建立一个基本认知沙箱不是一堵墙它更像是一套规则集合。这套规则集合要回答三个问题——Agent 能碰哪些资源、能执行哪些操作、操作结果能流向哪里。从技术实现上看目前主流的 Agent 沙箱方案大致分四类沙箱类型典型实现隔离强度适用场景进程级隔离子进程 权限降级低本地开发调试容器级隔离Docker、Podman中CI/CD、多租户虚拟机级隔离轻量 VM、microVM高生产环境、不可信代码语言级隔离WASM、受限解释器中高插件系统、嵌入式进程级隔离最常见也最容易被绕过。很多 Agent 工具默认就是在当前用户权限下起一个子进程然后告诉模型“你只能在这个目录里操作”。问题是模型生成的命令如果带了../或者绝对路径而执行层又没有做路径规范化校验那这个“只能”就是一句空话。容器级隔离看起来靠谱一些但如果你把宿主机的 Docker socket 挂进了容器或者用了--privileged模式那容器和宿主机之间基本就是一层纸。我见过不少团队为了图方便直接给 Agent 容器开了特权模式结果 Agent 一条mount命令就把宿主机根目录挂进来了。2.2 权限边界失效的四种典型路径结合我自己的踩坑经验和社区里公开的案例Agent 突破沙箱的路径基本可以归为四类。第一类路径穿越与符号链接。Agent 拿到一个“工作目录”参数理论上只能在这个目录里读写。但如果执行层没有对路径做realpath解析Agent 可以通过../../etc/passwd或者创建一个指向外部的符号链接来访问沙箱外资源。这类问题在早期很多 Agent 框架里都存在修复方式也很直接——所有路径操作前先做规范化然后校验前缀。第二类命令注入与解释器逃逸。Agent 要执行 shell 命令如果它生成的命令字符串被直接拼接进sh -c里那命令注入几乎是必然的。更隐蔽的是解释器逃逸比如 Agent 被允许运行 Python 脚本但脚本里可以import os然后os.system()这就等于绕过了命令白名单。防御思路是能力最小化如果只需要做文本处理就别给 Python 解释器给一个受限的表达式求值器就够了。第三类网络出口未收敛。很多团队在划权限边界时只关注文件系统忘了网络。Agent 如果能自由发起 HTTP 请求那它就可以把沙箱内的数据外传或者从外部拉取恶意载荷。正确的做法是默认拒绝所有出站流量只放行明确需要的域名和端口。第四类工具链供应链污染。Agent 调用的工具、依赖的包、加载的插件任何一个环节被污染沙箱边界就形同虚设。比如 Agent 被允许npm install任意包那一个恶意 postinstall 脚本就能拿到执行权限。这类问题的防御不在沙箱本身而在依赖管理和制品审计。注意这四类路径不是互斥的真实攻击往往是组合拳。先通过路径穿越读到配置文件再用配置里的凭据发起网络请求最后通过工具链落地持久化。所以权限边界设计必须是系统性的不能只堵一个口子。2.3 为什么“逃出沙箱”在 Agent 场景下更危险传统程序的行为空间是确定的你写多少代码它就做多少事。Agent 不一样它的行为空间是模型生成的而模型的输出具有不确定性。同一个任务今天它生成的是ls明天可能生成的是rm -rf。这种不确定性叠加权限边界设计缺陷风险就被放大了。更麻烦的是Agent 往往被赋予多步执行能力。它可以把一个大任务拆成若干小步骤每一步看起来都人畜无害但组合起来就完成了越界。比如第一步读一个环境变量第二步把变量值写进日志第三步把日志上传到某个地址。单看每一步都在权限内但整体行为已经突破了数据边界。这就是为什么我说Agent 的权限边界不能只按“单次操作”来划必须按任务上下文来划。你要问的不是“这个命令能不能执行”而是“这个命令在当前任务链路里应不应该被执行”。3. 权限边界设计的实操框架3.1 从“允许清单”转向“能力清单”很多团队设计权限边界时用的是允许清单思路列出 Agent 可以执行的命令、可以访问的目录、可以调用的 API。这个思路在简单场景下够用但在 Agent 场景下很快会失控因为模型能生成的命令组合是无穷的你列不全。我的建议是转向能力清单思路。不要问“Agent 能执行什么命令”要问“Agent 需要具备哪些能力”。比如读取指定目录下的文本文件在指定目录下创建和修改文件执行一组预定义的、参数化的操作调用一组预定义的、有 schema 约束的 API然后针对每种能力设计受限的执行原语。Agent 不直接生成 shell 命令而是生成对原语的调用由执行层负责把原语翻译成具体操作。这样模型的行为空间就被收敛到了原语集合内而不是整个 shell。举个例子。如果你需要 Agent 做文件搜索不要给它grep命令给它一个search_files(pattern, directory)原语。执行层收到这个调用后先校验directory是否在允许范围内再用安全的参数化方式调用底层搜索。模型无法通过构造特殊 pattern 来逃逸因为 pattern 只被当作搜索词不会被拼进命令。3.2 路径校验的正确姿势路径校验是权限边界里最基础也最容易做错的一环。我见过太多实现是这么写的def is_safe_path(path, base_dir): return path.startswith(base_dir)这个写法有至少三个问题。第一没有处理相对路径../../etc不会以base_dir开头但base_dir/../../etc会。第二没有处理符号链接一个指向外部的软链接路径是以base_dir开头的。第三没有处理大小写和编码问题在某些文件系统上Base_Dir和base_dir是同一个目录。正确的做法是三步走import os def is_safe_path(path, base_dir): # 第一步转成绝对路径并规范化 abs_path os.path.realpath(os.path.abspath(path)) abs_base os.path.realpath(os.path.abspath(base_dir)) # 第二步用 os.path.commonpath 判断包含关系 try: common os.path.commonpath([abs_path, abs_base]) except ValueError: return False # 第三步确认公共路径就是 base_dir return common abs_baserealpath会解析掉所有符号链接和..commonpath会正确处理路径分隔符和边界情况。这个实现不是绝对安全比如 TOCTOU 问题还需要额外处理但比startswith强了不止一个量级。提示路径校验必须在执行层做不能依赖模型自己遵守。模型可能会被提示词注入攻击也可能只是单纯地“理解错了”。执行层是最后一道防线这道防线不能假设上游是可信的。3.3 网络出口的收敛策略网络出口收敛的核心原则是默认拒绝显式放行。具体落地时我建议分三层来做。第一层是域名白名单。只允许 Agent 访问明确需要的域名其他一律拒绝。白名单要精确到域名不要用通配符尤其不要用*.example.com这种因为子域名可能被第三方控制。第二层是协议和方法限制。即使域名在白名单里也要限制可用的 HTTP 方法和请求路径。比如只允许GET只允许访问/api/v1/下的路径。这样即使 Agent 被诱导去访问白名单域名也无法触发敏感操作。第三层是请求内容审计。对出站请求的 body 做检查防止数据外传。这一步比较重通常只在安全要求高的场景下做。如果做建议用正则或规则引擎匹配敏感模式比如身份证号、密钥格式等。在容器环境下这三层可以通过网络策略、代理和 sidecar 组合实现。关键是不要让 Agent 直接持有网络能力让它通过一个受控的代理出去代理负责执行上述三层策略。3.4 工具链的依赖治理Agent 的工具链依赖治理核心是锁定版本 审计制品 最小依赖。锁定版本不用多说package-lock.json、poetry.lock、Cargo.lock这些锁文件必须提交到版本控制安装时用--frozen或等价参数。审计制品是指对引入的包做安全扫描至少要知道每个包从哪来、有没有已知问题。最小依赖是指只引入真正需要的包每多一个依赖就多一个攻击面。对于 Agent 场景还有一条额外建议不要让 Agent 自己安装依赖。如果 Agent 需要某个能力应该由人来评估、引入、锁定而不是让 Agent 在运行时pip install或npm install。运行时安装依赖等于把供应链安全的控制权交给了模型这是非常危险的。4. 一个可复现的权限边界实现方案4.1 整体架构下面给一个我实际用过的方案基于容器 受限执行原语 网络代理三层结构。这个方案不依赖特定云厂商本地和服务器都能跑。架构分三个组件Agent 运行时跑在容器里只有受限的文件系统视图和受限的执行原语没有直接网络访问。执行原语层跑在宿主机或独立容器里接收 Agent 的原语调用做路径校验、参数校验后执行。网络代理跑在独立容器里执行域名白名单和请求审计Agent 的所有出站流量都走它。Agent 运行时和执行原语层之间通过 Unix socket 或本地 HTTP 通信网络代理通过 Docker 网络策略强制 Agent 只能访问它。4.2 容器配置要点Agent 容器的 Dockerfile 和运行参数有几个关键点FROM python:3.11-slim # 创建非 root 用户 RUN useradd -m -u 1000 agent USER agent WORKDIR /home/agent/workspace # 只复制必要的运行时 COPY --chownagent:agent agent_runtime/ /home/agent/runtime/运行时的关键参数docker run \ --read-only \ --tmpfs /tmp:size64m \ --cap-dropALL \ --security-optno-new-privileges \ --networkagent-internal \ --memory512m \ --cpus1 \ -v /host/workspace:/home/agent/workspace:rw \ agent-runtime逐个解释这些参数。--read-only让容器根文件系统只读Agent 只能往挂载的 workspace 和 tmpfs 里写。--cap-dropALL丢掉所有 Linux capability防止 Agent 做 mount、ptrace 等操作。--security-optno-new-privileges防止通过 setuid 程序提权。--networkagent-internal把 Agent 放进一个只能访问代理的隔离网络。内存和 CPU 限制防止资源耗尽。注意--read-only和--tmpfs的组合很关键。很多程序需要写临时文件如果不给 tmpfs程序会报错开发者可能就会去掉--read-only。给一个受限的 tmpfs 既满足需求又保持隔离。4.3 执行原语层的实现执行原语层是权限边界的核心。下面是一个简化的 Python 实现展示路径校验和原语分发import os import json from pathlib import Path WORKSPACE Path(/host/workspace).resolve() def safe_resolve(rel_path: str) - Path: 把相对路径解析到 workspace 内越界则抛异常 candidate (WORKSPACE / rel_path).resolve() if not str(candidate).startswith(str(WORKSPACE) os.sep): raise PermissionError(fpath escapes workspace: {rel_path}) return candidate def op_read_file(args: dict) - dict: path safe_resolve(args[path]) if not path.is_file(): raise FileNotFoundError(args[path]) if path.stat().st_size 1024 * 1024: raise ValueError(file too large) return {content: path.read_text(encodingutf-8)} def op_write_file(args: dict) - dict: path safe_resolve(args[path]) content args[content] if len(content) 1024 * 1024: raise ValueError(content too large) path.parent.mkdir(parentsTrue, exist_okTrue) path.write_text(content, encodingutf-8) return {ok: True} def op_search_files(args: dict) - dict: pattern args[pattern] directory safe_resolve(args.get(directory, .)) results [] for p in directory.rglob(*): if p.is_file() and pattern in p.name: results.append(str(p.relative_to(WORKSPACE))) if len(results) 100: break return {matches: results} PRIMITIVES { read_file: op_read_file, write_file: op_write_file, search_files: op_search_files, } def dispatch(request: dict) - dict: name request.get(op) if name not in PRIMITIVES: return {error: funknown op: {name}} try: return {result: PRIMITIVES[name](request.get(args, {}))} except Exception as e: return {error: str(e)}这个实现有几个设计取舍值得说明。第一safe_resolve用resolve()解析符号链接用startswith(WORKSPACE os.sep)做前缀校验比commonpath更严格commonpath在路径完全相同时会返回相等需要额外判断。第二每个原语都有大小限制防止 Agent 通过大文件读写耗尽资源。第三搜索原语限制了结果数量防止 Agent 通过宽泛搜索枚举整个文件系统。4.4 网络代理的配置网络代理用一个小型反向代理实现核心是域名白名单和请求日志。下面是一个基于 Python 的简化实现from http.server import BaseHTTPRequestHandler, HTTPServer import urllib.request import urllib.parse ALLOWED_HOSTS {api.example.com, cdn.example.com} ALLOWED_METHODS {GET, POST} class ProxyHandler(BaseHTTPRequestHandler): def do_GET(self): self._handle(GET) def do_POST(self): self._handle(POST) def _handle(self, method): if method not in ALLOWED_METHODS: self.send_error(405) return parsed urllib.parse.urlparse(self.path) host parsed.netloc or self.headers.get(Host, ) if host not in ALLOWED_HOSTS: self.send_error(403, host not allowed) return # 读取请求体并转发 length int(self.headers.get(Content-Length, 0)) body self.rfile.read(length) if length else None req urllib.request.Request( self.path, databody, methodmethod, headers{k: v for k, v in self.headers.items() if k.lower() not in (host, content-length)} ) try: with urllib.request.urlopen(req, timeout10) as resp: self.send_response(resp.status) for k, v in resp.headers.items(): if k.lower() not in (transfer-encoding, connection): self.send_header(k, v) self.end_headers() self.wfile.write(resp.read()) except Exception as e: self.send_error(502, str(e)) if __name__ __main__: HTTPServer((0.0.0.0, 8080), ProxyHandler).serve_forever()这个代理很简陋生产环境需要加认证、限流、请求体审计、TLS 终止等。但它展示了核心思路Agent 不直接访问外网所有流量经过代理代理按白名单放行。在 Docker 网络里把 Agent 容器的默认网关指向代理容器或者用--network配合 iptables 规则强制流量走代理。4.5 把原语暴露给 Agent最后一步是把原语暴露给 Agent。有两种方式一种是让 Agent 通过工具调用function calling来调原语另一种是给 Agent 一个本地 CLICLI 内部走 socket 调原语层。我倾向于后者因为 CLI 对模型更友好模型只需要生成agent-cli read-file --path foo.txt这样的命令不需要理解 function calling 的 schema。CLI 内部做参数解析和 socket 通信模型看不到底层细节。# agent-cli 的简化实现 import sys import json import socket def call_primitive(op, args): with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s: s.connect(/var/run/agent-primitives.sock) s.sendall(json.dumps({op: op, args: args}).encode()) data s.recv(1024 * 1024) return json.loads(data) if __name__ __main__: op sys.argv[1] args {} for i in range(2, len(sys.argv), 2): args[sys.argv[i].lstrip(-).replace(-, _)] sys.argv[i 1] print(json.dumps(call_primitive(op, args), ensure_asciiFalse))这样 Agent 的权限边界就收敛到了三个原语上它无法执行任意 shell 命令无法访问 workspace 外的文件无法直接发起网络请求。即使模型被提示词注入攻击它能造成的破坏也被限制在这个范围内。5. 常见问题与排查技巧实录5.1 权限边界相关的典型问题速查下面这张表是我在实际运维中整理出来的覆盖了大部分常见问题现象可能原因排查方向修复建议Agent 能读到 workspace 外文件路径校验缺失或用了 startswith检查执行层路径处理逻辑改用 realpath 前缀校验Agent 能执行任意命令直接拼接 shell 命令检查命令构造方式改用参数化调用或原语Agent 能访问外网网络策略未收敛检查容器网络配置默认拒绝 白名单放行Agent 安装依赖后行为异常供应链污染审计 lock 文件和制品锁定版本 禁止运行时安装Agent 占用资源过高缺少资源限制检查容器资源参数加 memory/cpu 限制Agent 写入敏感目录挂载点配置过宽检查 volume 挂载只挂载必要目录只读优先5.2 几个容易忽略的细节第一个细节环境变量泄露。很多人只关注文件系统和网络忘了环境变量。Agent 进程能读到所有继承的环境变量如果里面有 API key、数据库密码那 Agent 就能拿到。正确做法是启动 Agent 时清理环境变量只传必要的几个。docker run --env-file /dev/null -e ONLY_THISvalue ...或者用env -i在启动脚本里清空环境。第二个细节日志和审计。权限边界不只是“防住”还要“可追溯”。Agent 的每一次原语调用、每一次网络请求都应该记日志日志要包含时间、操作、参数、结果。出问题时这些日志是唯一的线索。日志本身也要注意脱敏别把敏感数据写进去。第三个细节超时和中断。Agent 可能陷入死循环或者执行一个超长任务。执行原语层必须有超时机制超时后强制终止并返回错误。网络代理也要有超时防止 Agent 通过慢速请求耗尽连接。第四个细节并发控制。如果多个 Agent 共享同一个 workspace要防止它们互相干扰。可以用文件锁也可以给每个 Agent 分配独立子目录。共享资源比如网络代理要做限流防止一个 Agent 把配额用光。5.3 排查思路从现象到根因遇到“Agent 行为异常”时我的排查顺序是这样的。第一步确认现象。是 Agent 执行了不该执行的命令还是访问了不该访问的资源还是产出了不该产出的内容。现象不同排查方向完全不同。第二步看日志。执行原语层的日志能告诉你 Agent 调了什么原语、传了什么参数。网络代理的日志能告诉你 Agent 访问了什么地址。如果日志里没有异常那问题可能出在日志没覆盖到的地方比如 Agent 直接调用了某个未受控的工具。第三步复现。用相同的输入重跑一次看能不能复现。Agent 的行为有随机性单次异常可能是偶发。如果能稳定复现就可以逐步缩小范围。第四步隔离。把 Agent 的权限进一步收紧看异常是否消失。如果收紧后消失说明问题就在被收紧的那部分权限上。逐步收紧逐步定位。第五步修复并验证。修复后要用同样的输入重跑确认异常不再出现。同时要检查有没有类似的路径没被堵上避免按下葫芦浮起瓢。提示排查时不要假设“模型不会这么做”。模型的行为空间很大它可能以你完全没想到的方式组合工具。排查要基于日志和事实不要基于对模型行为的直觉。5.4 一个真实的踩坑记录我之前搭过一套 Agent 用来做代码审查给它配了读文件、跑测试、发评论三个原语。跑了一段时间都正常直到有一次它把一个内部仓库的代码片段贴到了公开的评论里。排查下来发现问题出在“发评论”原语上。这个原语接受一个repo参数和一个content参数执行层只校验了repo在允许列表里没校验content的内容。Agent 在审查一个内部仓库时把代码片段作为评论内容发到了另一个公开仓库的 issue 里。修复方案是给“发评论”原语加了内容审计检测到疑似代码片段就拒绝发送并告警。同时把原语的参数设计改了不再接受自由文本content而是接受结构化的finding对象由执行层负责渲染成评论。这样 Agent 就无法通过构造 content 来外传数据了。这个坑的教训是权限边界要覆盖数据流不只是操作流。Agent 能执行什么操作是一回事操作产生的数据流向哪里是另一回事。两者都要管。6. 关于 Agent 权限边界的几点个人体会做 Agent 开发这几年我越来越觉得权限边界不是一个“配好了就不用管”的东西而是一个需要持续维护的过程。模型在进化工具在增加攻击面在变化边界也要跟着调整。我的做法是每次给 Agent 加新能力时都问三个问题这个能力最小化的形式是什么这个能力的失败模式是什么这个能力被滥用时最坏的结果是什么三个问题答不上来就不加。另外不要追求“绝对安全”。Agent 的价值在于它能做很多事把它锁死到什么都做不了那还不如不用。目标是风险可控知道最坏情况是什么知道怎么发现知道怎么止损。在这个前提下给 Agent 足够的空间去干活。最后分享一个实用技巧给 Agent 的权限边界做定期演练。每隔一段时间故意用一些边界测试用例去试探 Agent看它会不会越界。这跟安全领域的渗透测试是一个思路。演练发现的漏洞比线上出事后再发现要好得多。
返回列表