把 AI Agent 关进“沙箱“:用 Python 手写本地隔离执行运行时
把 AI Agent 关进"沙箱":用 Python 手写本地隔离执行运行时
📖 摘要:工具网关能拦越权调用,却挡不住 Agent 在宿主机上"乱摸"。本文讲清为什么 Agent 需要一层"执行隔离运行时",并用纯标准库手写最小可运行沙箱:默认拒绝的白名单执行器 + 文件系统边界 + Append-Only 动作日志(可回放、可审计)。读完你将掌握隔离边界与可控回放的核心机制,并带走一份能直接跑的骨架代码与 4 条生产避坑。
🏷️ 关键词:Agent 沙箱,执行隔离,Append-Only 日志,本地优先,Python 实战
目录
一、为什么 Agent 需要"沙箱运行时"
过去两周开源社区的信号很集中:Agent 框架已经不缺"大脑"(记忆、Skills、协议、编排都卷完了),缺的是把 Agent 真正"关"起来的地方。2026-08-24 的 GitHub 周榜里,microsandbox(Rust 写的本地优先 microVM)、Apache Maka(本地优先 Agent 工作台,把模型消息和工具调用以 append-only 日志落盘)、agent-substrate、munder-difflin(本地多 Agent Harness)集体登榜——它们解决的不是"Agent 聪不聪明",而是"Agent 跑起来安不安全、出了事能不能复盘"。
很多团队的第一反应是:我们不是已经做了工具网关(Guardrails)吗?确实,策略门禁能拦"不该调用的工具",但它管的是"决策层"——Agent 一旦被放行去读文件、跑脚本,网关就管不着它在宿主机上具体摸了什么。真正的生产事故往往出在这两个地方:
- 路径逃逸:放行了
read_file,但 Agent 把path填成../../etc/passwd,网关看不出问题,文件系统却裸奔了。 - 无据可查:Agent 半夜误操作删了一堆东西,你只有一地鸡毛,没有任何"动作账本"能回放它到底做了什么。
这就是"沙箱运行时"要补的层:它不负责判断"该不该做"(那是网关的事),而是负责"即使被放行,也只能在画好的圈里做,并且每一步都留痕"。
二、核心原理:隔离边界 + 不可变日志
2.1 隔离边界:从"策略门禁"到"执行边界"
把 Agent 的执行环境想象成一个"玻璃房":
- 策略门禁(已有):决定哪些工具能进房间,对应前文的安全护栏。
- 执行边界(本文新增):即使工具进了房间,也只能碰房间里规定的东西。具体到代码,就是两条硬规则:
- 默认拒绝:任何不在白名单里的工具,一律不执行(fail-closed,比 fail-open 安全)。
- 资源/路径封顶:文件类操作被强制约束在一个隔离工作目录内,路径字符串里一旦出现
..越界就直接拦截,绝不os.path.join到房外。
💡 小贴士:隔离边界和策略门禁是互补而非替代。网关说"可以读文件",边界再补一句"只能读
/sandbox里的"。
2.2 Append-Only 日志:可回放的动作账本
Apache Maka 之所以被社区盯上,核心卖点就是把 Agent 的每一步(模型消息、工具调用、返回结果)写成只能追加、不能改写的日志(append-only log)。它带来两个能力:
- 审计:事后能逐条看 Agent 干了什么,谁的责任一目了然。
- 回放/断点恢复:日志是状态机的事实来源(source of truth),进程挂了可以从最后一条记录续跑,而不是从零开始。
每条记录带一个链式哈希(把序号 + 内容算进 hash),任何对历史日志的篡改都会让链条断掉——这就是"不可变"的工程实现。
三、实战落地:手写最小 Agent 沙箱运行时
3.1 环境准备
纯标准库即可,无需 Docker、无需第三方包,Python 3.8+ 都能跑:
# 确认版本
python --version # 3.8+ 即可
# 直接运行(无需安装依赖)
python sandbox_runtime.py
真实生产里你会用 microsandbox(microVM 级隔离)或容器把边界做硬;本文用 subprocess + 路径约束演示原理骨架,重点在"默认拒绝 + 边界 + 留痕"三件事的执行流,而不是隔离强度。
3.2 核心实现:隔离执行器 + 动作日志
下面是一份可直接运行的最小骨架(约 70 行):
import json, os, hashlib, tempfile, time
# ① 隔离工作目录:文件类工具只能读写这里,宿主机其它路径碰不到
WORKDIR = tempfile.mkdtemp(prefix="agent_sandbox_")
# ② 默认拒绝:只有白名单里的工具才可能被放行
ALLOWED_TOOLS = {"read_file", "write_file", "list_dir", "shell"}
class AppendLog:
"""Append-Only 动作账本:每条记录链式哈希,可回放、可审计。"""
def __init__(self, path):
self.path = path
self.seq = 0
def record(self, tool, args, result, ok):
self.seq += 1
payload = json.dumps({"tool": tool, "args": args,
"result": result, "ok": ok}, ensure_ascii=False)
# 把序号算进哈希,篡改历史会让链条断裂
h = hashlib.sha256(f"{self.seq}{payload}".encode()).hexdigest()[:16]
action = {"seq": self.seq, "ts": time.time(), "tool": tool,
"args": args, "result": result, "ok": ok, "h": h}
with open(self.path, "a", encoding="utf-8") as f:
f.write(json.dumps(action, ensure_ascii=False) + "\n")
return action
def replay(self):
"""从不可变日志回放,用于审计或断点恢复。"""
out = []
with open(self.path, encoding="utf-8") as f:
for line in f:
out.append(json.loads(line))
return out
class Sandbox:
"""最小 Agent 沙箱运行时:默认拒绝 + 路径边界 + 动作留痕。"""
def __init__(self):
self.log = AppendLog(os.path.join(WORKDIR, "actions.log"))
def run(self, tool, **args):
# 第一道:默认拒绝——不在白名单直接拦截,绝不放行
if tool not in ALLOWED_TOOLS:
a = self.log.record(tool, args, "blocked: not in allowlist", False)
return False, a["result"], a
# 第二道:文件系统边界——只允许在 WORKDIR 内操作,防越权读 /etc/passwd
if tool in ("read_file", "write_file", "list_dir"):
p = args.get("path", "")
abs_p = os.path.abspath(os.path.join(WORKDIR, p))
if not abs_p.startswith(WORKDIR):
a = self.log.record(tool, args, "blocked: path escapes sandbox", False)
return False, a["result"], a
args = {**args, "path": abs_p}
# 放行并执行(shell 在真实场景应进一步收紧,此处仅演示骨架)
result = self._exec(tool, args)
a = self.log.record(tool, args, result, True)
return True, result, a
def _exec(self, tool, args):
if tool == "write_file":
with open(args["path"], "w", encoding="utf-8") as f:
f.write(args.get("content", ""))
return f"written {len(args.get('content', ''))} bytes"
if tool == "read_file":
with open(args["path"], encoding="utf-8") as f:
return f.read()[:200]
if tool == "list_dir":
return str(os.listdir(args["path"]))
if tool == "shell":
return "shell exec (demo, restricted)"
return "noop"
if __name__ == "__main__":
s = Sandbox()
# 场景 A:白名单内 + 路径合法 → 放行
print(s.run("write_file", path="note.txt", content="hello sandbox"))
# 场景 B:路径逃逸 → 拦截
print(s.run("read_file", path="../../etc/passwd"))
# 场景 C:非白名单工具 → 拦截
print(s.run("send_email", to="a@example.com"))
# 回放日志做审计
for a in s.log.replay():
print(a["seq"], a["tool"], a["ok"], a["h"])
3.3 跑起来:三个场景演示
直接 python sandbox_runtime.py,预期输出(WORKDIR 路径随机,故略去):
(True, 'written 13 bytes', {...}) # 场景 A:白名单+合法路径,放行
(False, 'blocked: path escapes sandbox', {...}) # 场景 B:../ 越界,拦截
(False, 'blocked: not in allowlist', {...}) # 场景 C:非白名单,拦截
1 write_file True <hash> # 日志回放:三步动作全部留痕,可审计
2 read_file False <hash>
3 send_email False <hash>
注意两点:被拦截的动作同样写进了日志(审计要覆盖"想干坏事"的记录,而不只是"干成了的");每条记录的 h 字段串成链条,改任何一条历史都会让后续哈希对不上。
四、踩坑与优化
4.1 逃逸面:别以为 subprocess 就是保险箱
路径 startswith(WORKDIR) 是软隔离,对付的是 Agent 的"手滑"和"天真越权",不是蓄意攻击。真正恶意的 payload(symlink 穿越、环境变量注入、/proc 映射)能绕过字符串判断。生产环境务必用硬隔离:microsandbox 的 microVM、gVisor、或最少容器 + 只读根文件系统 + seccomp。原则——软边界用于"防呆",硬边界用于"防敌"。
4.2 日志膨胀与隐私脱敏
Append-Only 日志只增不删,Agent 高频调用会让文件几天就爆。对策:① 按时间/序号分段滚动(rotate)归档到对象存储;② 日志里不要原样存敏感入参(如把 password 字段脱敏成 ***),否则"审计日志"反而成了"泄漏源"。
4.3 运行时边界 vs 网关职责(别让沙箱背锅)
常见误区:把"该不该调用某工具"的判断也塞进沙箱。正确分工是——网关管"能不能做"(策略),沙箱管"做了也伤不到哪"(边界+留痕)。职责混在一起,沙箱既要判策略又要管隔离,结果两边都不纯粹,出了事难定位。
4.4 跨进程状态与回放一致性
日志是状态机的事实来源,但只有日志还不够——如果 Agent 的中间态(如内存里的变量)没进日志,回放时会"日志说做了,程序状态却没跟上"。要把"可重放的恢复点"设计成日志 + 检查点快照双写:日志管动作序列,快照管某一刻的完整状态,二者对齐才能真断点续跑。
五、总结
Agent 从"会聊"走向"上岗干活",隔离与留痕是绕不过的最后一公里。本文用 70 行标准库代码,把"默认拒绝的白名单执行器 + 文件系统边界 + Append-Only 可回放日志"这一最小沙箱运行时跑通了:它补在策略网关之下、控制平面之中,专门解决"放行之后还能作什么妖"和"事后怎么复盘"两个问题。
落到工程上记住三句话:① 软边界防呆、硬边界防敌,别混用;② 被拦截的动作也要记日志,审计才完整;③ 回放一致性靠"日志 + 检查点"双写,单靠日志不够。后续可以顺着这条线往下写:接 microsandbox 做真实 microVM 隔离、给日志加签名防篡改、把快照对齐进 Harness 的断点续跑。
💡 觉得有用就点个赞/收藏,评论区聊聊你现在的 Agent 是怎么"关"起来的。
更多推荐


所有评论(0)