在沙箱中运行命令#

运行 shell 命令的工具,或者运行模型刚写好的程序的工具,拥有你的进程能做的一切。沙箱给这条命令一个更小的世界:它可以读取这台机器,只能写入你指定的位置,只有你允许时才能使用网络。内核会对这条命令以及它启动的每个进程强制执行这个边界。

沙箱和审批提示是两种不同的控制。沙箱决定命令*能*做什么;先询问用户则决定它*是否运行*。两者可以配合使用,例如搭配 ShellApprovalExtension。

使用会话#

SandboxSession 是使用沙箱的方式。说明命令可以在哪里写入、可以使用多少资源,然后像普通 Python 一样使用它。离开 with 代码块时,会话创建的一切都会被清理。

from zett_agent.sandbox.session import SandboxSession

with SandboxSession("/work/project", memory="512MB", cpus=1, cpu_seconds=60) as sb:
    sb.run("python build.py", check=True)
    print(sb.run("ls dist").stdout)

不指定工作区时,会话会创建一个私有目录并在退出时删除,这很适合运行别人写的程序:

with SandboxSession(memory="256MB", timeout=30) as sb:
    (sb.workspace / "job.py").write_text(code)
    result = sb.run(["python3", "job.py"], check=True)
# the workspace and its files are gone here

run 的行为类似 subprocess.run()。传入列表时直接运行程序、不做任何解析,对于由不可信文本拼出来的命令这是更安全的形式;传入字符串则通过 /bin/sh -c 运行,所以管道可以使用。check=True 会抛出 SandboxCommandError,其中带有完整的结果。在异步代码中使用 async with 和 await sb.arun(...)。

会话替你做了什么:

  • 写入被限制 在工作区和私有临时空间内。机器上的其他位置都是只读的。

  • 网络是关闭的,除非传入 network=True。

  • 机密不会进入。 ~/.ssh 这类凭据文件不可读取,命令拿到的是一份简短的环境变量允许列表,而不是你环境的拷贝。

  • 清理跟随代码块。 无论是正常退出还是因异常退出,关闭时都会取消仍在运行的命令、杀掉它们的进程组和后代进程,然后删除会话创建的目录。程序留下无法进入的目录也不会阻碍清理。即使你从未关闭会话,对象被销毁或解释器退出时同样会清理。有一种情况无法在所有平台上覆盖:见下文“脱离的进程”。

  • 你自己的目录绝不会被动。 你传入的 workspace 或 writable 路径只会被使用,并保持命令留下的样子。

这些选项直接对应下文介绍的底层策略:

选项

含义

workspace

命令可以写入的目录。省略则使用一个会被删除的私有目录。

memory、max_file_size

字节数,或形如 "512MB" 的字符串(K、M、G、T 都是 1024 的幂)。

cpus

核心数:0.5 表示半个核心。仅限 Linux。

cpu_seconds、processes、open_files

处理器时间、进程和线程数、文件描述符数。

network

True 表示不受限的网络;默认完全没有网络。

writable、deny_write、deny_read

更多可写目录,以及保持不可写或不可读的路径。

env、timeout

每条命令都会得到的环境变量,以及默认的时间限制(秒)。

backend、require_isolation、memory_strategy

强制指定后端、拒绝在无隔离的情况下运行,以及选择内存的限制方式。

备注

限制作用于每条命令,而不是整个会话:先后运行的两条命令各自都能得到完整的内存预算。

完整程序请 下载 sandbox.py。

def main() -> None:
    # Say what the program may use; the workspace is a private directory that is deleted on exit.
    with SandboxSession(memory="512MB", cpu_seconds=30, max_file_size="10MB", backend=pick_backend()) as sb:
        workspace = sb.workspace
        (workspace / "program.py").write_text(PROGRAM)

        # Like subprocess.run: a list runs the program directly, check=True raises on failure.
        result = sb.run([sys.executable, "program.py"], check=True)
        assert result.stdout.strip() == "ran"
        assert (workspace / "answer.txt").read_text() == "42"

        # A string runs through the shell, so pipes work.
        assert sb.run("ls | sort").stdout.split() == ["answer.txt", "program.py"]

        # A command that writes past its file-size limit is stopped by the kernel.
        huge = sb.run("head -c 50000000 /dev/zero > big.bin")
        assert not huge.succeeded
        assert (workspace / "big.bin").stat().st_size <= 10 * 1024**2

    # Leaving the block removed the workspace and everything in it.
    assert not workspace.exists()
    print("Sandbox ran the program and the command")

底层 API#

会话替你构建了下面这些组件。当你需要它没有提供的能力时可以直接使用它们:用 wrap 自己处理进程、让一个沙箱被多个工作区共用,或者构建一次策略并重复使用。

from zett_agent.sandbox.factory import create_sandbox
from zett_agent.sandbox.policy import SandboxPolicy

sandbox = create_sandbox(SandboxPolicy.workspace_write("/work/project"))
result = await sandbox.run(["python3", "build.py"], cwd="/work/project")

每个后端都返回相同的 SandboxResult:退出状态、标准输出和标准错误、是否超时、输出是否被截断,以及后端是否真的做了隔离。

描述边界#

一个 SandboxPolicy 说明要强制执行什么,每个后端再把它转换成自己的机制。它沿用了成熟的智能体沙箱共有的形态:读取默认开放,用拒绝列表隐藏机密;写入默认关闭,只有列出的路径才开放;网络默认关闭;另外有少数路径即使位于可写目录内也依然受保护。

字段

含义

writable_paths

进程可以写入的目录。为空表示只能写入临时文件。

denied_write_paths

即使位于可写路径内也保持不可写。拒绝优先于允许。

denied_read_paths

不可读取。请加入对你重要的机密。

network

NetworkMode.NONE(默认)或 NetworkMode.FULL。

protect_defaults

让 shell 和 git 的配置、.git/hooks 以及编辑器设置在可写路径内保持不可写,并让常见的凭据文件和容器套接字不可读。

inherit_environment

进程会收到哪些宿主环境变量。

limits

进程可以使用多少资源:内存、处理器时间、文件和进程。

路径必须是绝对路径(~ 会被展开),因此无论在哪里构建,策略的含义都相同。SandboxPolicy.read_only() 和 SandboxPolicy.workspace_write(*roots) 覆盖了两种常见形态。

环境变量是一份简短的允许列表,而不是你环境的拷贝。你 shell 里的 API 密钥不会到达命令,除非你用 sandbox.run(..., env={"TOKEN": value}) 显式传入。

受保护的文件之所以重要,是因为能写入你工作区的进程可以埋下稍后在沙箱之外运行的东西:一个 .git/hooks/pre-commit 脚本、.bashrc 里的一行、一个编辑器任务。它们保持不可写;默认的读取保护还会隐藏 ~/.ssh、~/.aws、~/.gnupg 等,因为既能读取凭据又能访问网络的进程可以把它发送到任何地方。

限制进程的资源使用#

边界规定进程可以*在哪里*行动,预算规定它可以使用*多少*资源,正是后者让一条失控的命令不至于把整台机器拖垮。两者都在同一个策略里:

from zett_agent.sandbox.policy import ResourceLimits, SandboxPolicy

limits = ResourceLimits(
    memory_bytes=512 * 1024**2,   # the whole tree, not one process
    cpu_seconds=30,               # processor time, so sleeping costs nothing
    cpu_percent=50,               # half of one core
    processes=64,
    file_size_bytes=10 * 1024**2,
    open_files=256,
)
policy = SandboxPolicy(writable_paths=["/work/project"], limits=limits)

字段

含义

memory_bytes

进程及其启动的所有进程可以使用的内存。

cpu_seconds

在被内核杀死之前可以消耗的处理器时间总量。只睡眠的程序不消耗它。

cpu_percent

单个处理器的占用比例(前提是内核能够限制比例)。100 表示一个核心。

processes

命令可以新增的进程数。Linux 同时统计线程,macOS 不统计。

file_size_bytes

进程可以创建或扩展的最大文件大小。

open_files

进程可以同时打开的文件描述符数量。

core_dumps

默认关闭:大型进程的 core 文件很大,而且包含它读取过的内容。

限制是一种请求,而不是承诺。内核能做到什么取决于平台,沙箱会使用这台机器上最强的手段:

限制项

macOS

Linux

memory_bytes

采样看门狗。内核没有可设置的内存上限:macOS 拒绝 POSIX 的地址空间限制,而且没有 cgroup。

在有可用 cgroup 时使用 memory.max(见下方说明);否则使用 POSIX 地址空间限制(按进程计算)。

cpu_seconds

POSIX 限制(RLIMIT_CPU),由整个进程树继承。

同上。

cpu_percent

不可用;没有任何机制可以限制处理器占用比例。

cgroup 的 cpu.max,以 100 毫秒为周期分配配额。

processes

POSIX 限制(RLIMIT_NPROC)。内核拿它与该用户的所有进程比较,因此实际设置的是当前已运行的进程数加上你给的数字。线程不计入。

可用时使用 cgroup 的 pids.max(统计整棵进程树,包含线程);否则像 macOS 一样使用 RLIMIT_NPROC,但线程会被计入。

file_size_bytes

POSIX 限制(RLIMIT_FSIZE)。

同上。

open_files

POSIX 限制(RLIMIT_NOFILE)。

同上。

core_dumps

POSIX 限制(RLIMIT_CORE)。

同上。

备注

cgroup 相关的行只有在当前用户能够创建启用了控制器的子 cgroup 时才适用。内核只允许在自身不包含进程的 cgroup 中这样做,所以登录 shell 或服务所在的 cgroup 不满足条件;这需要委派,例如 systemd 的 Delegate=yes。例如 GitHub Actions 的 runner 的 cgroup 属于 root,不具备这些条件,所以在那里内存会退回到地址空间限制,cpus 则无法执行。cgroup 相关代码只在伪造的 cgroup 目录树上测试过,从未在真实内核上运行过。想知道某台机器的实际行为,请调用 capabilities()。

Windows 一项都无法强制执行,因此要求这些限制的策略会收到相应的警告。在任何平台上,无法强制执行的限制都会出现在 result.resources.warnings 中,而不是被悄悄跳过;也绝不会把未被执行的事情报告成已执行。

运行之前先问清楚#

capabilities() 会返回每项限制在这台机器上*将会*如何被执行,而且不会创建或修改任何东西。宿主可以拒绝这台机器无法按原样执行的策略,而不必等到运行时才发现:

report = sb.capabilities()  # or sandbox.capabilities() on the lower-level API
if report.memory not in ("cgroup", "rlimit_as"):
    raise RuntimeError(report.warnings)

result = await sandbox.run(["python3", "build.py"], cwd="/work/project")
print(result.resources.mechanisms, result.resources.peak_memory_bytes, result.resources.limits_hit)

result.resources 还会报告本次运行测得的数据:内存使用峰值,以及进程触达了哪些限制(memory、cpu 或 file_size)。

选择内存限制的方式#

没有哪一种机制适合所有程序,因此 memory_bytes 可以指定策略。在构建沙箱时传入:

策略

行为

MemoryStrategy.AUTO

默认值:依次尝试 cgroup、地址空间限制、看门狗。

MemoryStrategy.KERNEL

只使用内核限制,没有可用机制时给出警告。它精确,但地址空间限制统计的是程序*映射*的内存而非实际使用的内存,因此沙箱里的虚拟机或 JVM 可能在被拒绝时根本没有触碰那块内存。

MemoryStrategy.WATCHDOG

只使用看门狗。启动阶段永远不会被拒绝,而限制可能被两次采样之间分配的内存超出(默认间隔 0.25 秒,每次采样执行一次 ps)。

MemoryStrategy.NONE

不做任何强制执行,并如实说明。

from zett_agent.sandbox.resources import MemoryStrategy
from zett_agent.sandbox.session import SandboxSession

strongest = create_sandbox(policy)  # whatever this machine does best
exact = create_sandbox(policy, memory_strategy=MemoryStrategy.KERNEL)
generous = SandboxSession(memory="512MB", memory_strategy="watchdog")

警告

资源限制保护机器免受失控进程的影响,这正是生成代码常见的失败方式。它不是用来防御刻意逃逸的进程:看门狗可能被两次采样之间的分配超出,而进程可以尽可能快地用光它被允许的资源。

这里没有做什么#

  • 没有磁盘总量配额。 写入被限制在策略给定的路径内,单个文件也可以设上限,但工作区的总大小没有任何约束。

  • 没有带宽限制,也不限制网络可以访问*哪些*主机:参见上文关于代理的说明。

  • 没有按限制项设置的宽限期或优先级:限制是一条硬线,触碰它就会结束进程。

各平台提供什么#

平台

后端

说明

macOS

SeatbeltSandbox

系统自带,无需安装。使用 sandbox-exec,Apple 已将其弃用,但仍随系统提供。

Linux

BubblewrapSandbox

需要安装 bubblewrap,并要求支持非特权用户命名空间。

Windows

PassthroughSandbox

没有隔离,也没有资源限制:Windows 既没有 rlimit 也没有 cgroup。接口保持一致,isolated 为 False,每一项被要求的限制都会报告为未执行。

create_sandbox() 会选择该平台自己的后端。

重要

存在但无法运行的后端会抛出 SandboxUnavailableError,并说明需要安装或更改什么。沙箱层绝不会因此退而在没有沙箱的情况下运行命令。唯一会降级的情况是平台根本没有后端:create_sandbox 会返回 PassthroughSandbox 并发出 SandboxWarning。传入 require_isolation=True 可以让这种情况也变成错误。

自己启动进程#

run 建立在 wrap 之上,它会把命令改写成在沙箱内运行的形式并返回,但不会运行任何东西。自己管理进程的宿主(例如要流式输出或使用 pty)可以自行启动这个结果:

command = sandbox.wrap(["make", "test"], cwd="/work/project")
process = await asyncio.create_subprocess_exec(
    *command.argv, cwd=command.cwd, env=dict(command.env), start_new_session=True
)

请让它在自己的进程组中启动,这样才能停止整棵进程树。

run 在进程之外增加了什么#

所有后端共用这部分,因此除了能限制的内容不同之外,代码的行为是一致的:

  • 时间限制(默认 60 秒,None 表示不限制)。超时后进程组及其后代进程会被杀死。

  • 每个输出流保留的字节数上限(默认 1 MiB)。超出的部分会被读取并丢弃,因此无休止打印的进程既不会占满内存,也不会因管道写满而阻塞。

  • 命令结束后,它进程组里的辅助进程不会被遗留。它们会随之停止,仍然占用输出管道的子进程也不能让已经结束的运行继续等待。

  • 取消正在等待的任务时,会先停止进程组及其后代进程,再传播取消。

脱离的进程#

开启了自己会话的子进程(setsid,或 Python 里的 start_new_session)会离开父进程的进程组。只要父进程还活着,仍然可以通过进程树找到它,所以超时、取消或关闭会话都能停止它。如果父进程已经退出,就没有任何东西把这个子进程与命令关联起来了,在 macOS 上它会在运行结束、会话关闭之后继续运行。在 Linux 上,沙箱运行在自己的 PID 命名空间里,沙箱结束时内核会终结其中的所有进程,因此不会出现这种情况。

它不做什么#

依赖它之前,请先弄清楚这些:

  • 不按主机过滤。 网络要么全开要么全关。只允许指定域名需要代理,这里不包含代理。

  • Linux 上没有 seccomp。 网络开放时,进程共享宿主的网络命名空间,可以访问那里的本地服务。关闭网络(默认)则会把它们隔离开。

  • 没有磁盘总量配额,也没有带宽限制,理由同上。

  • macOS 的网络和套接字。 NetworkMode.FULL 会全部开放,包括本地服务。NONE 则全部关闭。

  • 尚不存在的受保护路径。 macOS 会阻止它被创建。在 Linux 上,路径只有存在之后才能被保护。

  • 拒绝是无声的。 被阻止的操作表现为命令自己的 "Operation not permitted" 错误。沙箱不会报告违规。

  • 嵌套。 已经处于 Seatbelt 沙箱内的进程无法再启动另一个;check 会说明这一点。

  • Windows。 完全没有隔离。

  • 无法防御内核或沙箱本身的漏洞, 也无法防止进程滥用你确实允许的访问权限。

在 Linux 上,工作目录必须在沙箱内可见。那里的 /tmp 是私有的 tmpfs,所以 /tmp 下的目录只有在它本身是可写路径、或位于可写路径之内时才可见。

下一步:参阅 定义并注册工具 了解它所保护的 shell 工具,或参阅 组合内置扩展 了解审批扩展。

相关 API:SandboxSession、Sandbox、SandboxPolicy、ResourceLimits、ResourceReport、MemoryStrategy、create_sandbox()。