把 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-substratemunder-difflin(本地多 Agent Harness)集体登榜——它们解决的不是"Agent 聪不聪明",而是"Agent 跑起来安不安全、出了事能不能复盘"。

很多团队的第一反应是:我们不是已经做了工具网关(Guardrails)吗?确实,策略门禁能拦"不该调用的工具",但它管的是"决策层"——Agent 一旦被放行去读文件、跑脚本,网关就管不着它在宿主机上具体摸了什么。真正的生产事故往往出在这两个地方:

  1. 路径逃逸:放行了 read_file,但 Agent 把 path 填成 ../../etc/passwd,网关看不出问题,文件系统却裸奔了。
  2. 无据可查: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 是怎么"关"起来的。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐