Agent Sandbox 从零开始 · 3.0
第 10 / 34 节 · 面向零基础读者

第 8 章:亲手运行一个受限任务

第一步:准备数据和计算程序

用文本编辑器创建 input/sales.csv,内容如下:

item,amount
book,120
course,80
notebook,50

再创建 input/analyze.py:

import csv
import json
from decimal import Decimal
from pathlib import Path

with Path('/input/sales.csv').open(newline='') as f:
    rows = list(csv.DictReader(f))
total = sum((Decimal(r['amount']) for r in rows), Decimal('0'))
report = {'rows': len(rows), 'total': str(total)}
Path('/output/report.json').write_text(
    json.dumps(report, ensure_ascii=False), encoding='utf-8'
)
print('Report saved.')

Decimal 用来按十进制计算金额。示例只处理已知的小文件;真实系统还应检查文件大小、字段名、金额格式和异常数据,不能无界读取陌生文件。

第二步:创建控制器

在实验目录创建 run_demo.py。下面的代码直接调用 Docker,不会安装 Python 依赖。它显式指定一个容器名,因此超时后能找到并停止同一个容器。

import json
import os
from pathlib import Path
import subprocess
import uuid

base = Path(__file__).resolve().parent
out = base / 'output'
out.mkdir(exist_ok=True)
report = out / 'report.json'
if report.exists():
    raise SystemExit('请先把旧 report.json 移走,再运行。')
name = 'sandbox-demo-' + uuid.uuid4().hex[:12]
uid = os.getuid()
gid = os.getgid()
cmd = [
    'docker', 'run', '--rm', '--pull=never', '--name', name,
    '--network=none', '--read-only',
    '--memory=128m', '--memory-swap=128m', '--cpus=0.5',
    '--pids-limit=32', '--cap-drop=ALL',
    '--security-opt=no-new-privileges:true',
    '--user', f'{uid}:{gid}',
    '--tmpfs', '/tmp:rw,noexec,nosuid,size=16m,mode=1777',
    '--mount', f'type=bind,src={base / "input"},dst=/input,readonly',
    '--mount', f'type=bind,src={out},dst=/output',
    'python:3.12-slim', 'python', '-B', '/input/analyze.py'
]
try:
    result = subprocess.run(cmd, timeout=30, check=False)
    if result.returncode != 0:
        raise SystemExit(f'任务失败,退出码:{result.returncode}')
    if report.is_symlink() or not report.is_file():
        raise SystemExit('没有找到普通报告文件')
    if report.stat().st_size > 4096:
        raise SystemExit('报告过大')
    data = json.loads(report.read_text(encoding='utf-8'))
    if data != {'rows': 3, 'total': '250'}:
        raise SystemExit(f'报告与预期不符:{data}')
    print('验证通过:3 笔数据,总额 250。')
except subprocess.TimeoutExpired:
    raise SystemExit('任务超过 30 秒,正在清理容器。')
finally:
    cleanup = subprocess.run(
        ['docker', 'rm', '-f', name],
        stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
        text=True, timeout=15
    )
    if cleanup.returncode and 'No such container' not in cleanup.stderr:
        print('请检查容器是否已清理:', name)

在普通 Linux 环境中,使用当前用户的 UID/GID 有助于保证绑定目录可写。Docker Desktop 会处理宿主机文件共享,但企业策略、SELinux、rootless 模式和用户映射可能影响访问;报错时先查看具体原因,不要直接取消所有权限限制。包含逗号的目录路径会影响 –mount 参数解析,本实验请使用简单路径。

第三步:运行并观察

python3 run_demo.py
cat output/report.json

预期先看到 Report saved.,再看到验证通过,报告包含 rows 为 3、total 为字符串 250。JSON 键顺序不重要。你已经完成一次“控制器提交任务—受限环境执行—控制器校验产物”的流程。

这里的绑定输出目录会真实修改宿主机的实验目录。只给一个空的专用 output 目录,是为了让这些写入有明确范围。不要给已有的重要文档目录作为输出。

每个限制做了什么

–network=none 关闭容器的常规外部网络连接;–read-only 让容器根文件系统只读;输入挂载的 readonly 保护输入目录免遭容器修改。输出挂载允许程序写报告。

–memory 与 –memory-swap 在支持的 Linux 配置中限制内存和交换使用;实际支持以 Docker 和内核为准。–cpus 限制 CPU 配额,–pids-limit 限制进程数量。Python 控制器的 timeout 给 Docker 客户端设置等待上限,并在 finally 中显式清理容器。[3]

这些限制没有给宿主机 output 目录设置硬磁盘配额,也没有实现多租户隔离、恶意产物检查或全局费用限制。它是理解原理的教学起点。

本书包含示例与资料导读;验证范围和来源见附录。