Monty 资源限制正确性模型:从 preflight 分配到终止性错误展开的完整指南
【免费下载链接】montyA minimal, secure Python interpreter written in Rust for use by AI项目地址: https://gitcode.com/GitHub_Trending/monty3/monty
Monty(crates/monty)是一个用 Rust 编写的、面向 AI 场景的极简安全 Python 解释器,其核心威胁模型是"沙箱内运行不可信代码"。本文围绕仓库内 .macroscope/correctness/resource-limits.md 确立的资源限制正确性标准,结合 docs/resource-limits.md 用户文档与ResourceTracker、monty-alloc、StringBuilder等源码实现,完整讲解内存、时间、递归三类上限的底层机制,以及"哪些分配必须预先计费、哪些原生循环必须轮询、限制触发后沙箱与 Rust 侧各自承担什么语义"这一套可操作的审查与实现准则。读完本文,你将掌握 Monty 资源限制的完整架构,并能据此判断一段解释器代码是否存在资源限制逃逸。
为什么资源限制是沙箱的生死线
沙箱中的 Python 代码可以驱动分配('x' * 10**12、2 ** 10_000_000、无限循环拼接字符串),而沙箱的宿主进程必须承受这些分配带来的内存与 CPU 开销。.macroscope/correctness/resource-limits.md给出的模型由三条原则构成:
- 任何大小可被攻击者影响的分配,都必须预先计费(preflight):在真正分配之前,将"将要实际分配的大小"计入
ResourceTracker(通过check_allocation),而不是等到分配发生之后才由轮询发现; - 内存与时间由不同的机制守护:内存靠"分配前预检 + 分配器软/硬两级限额",时间靠"执行过程中的检查点轮询",二者不可互相替代;
- 限制失败对沙箱是终止性的,但对 Rust 侧清理不是:一旦内存或时间上限触发,Python 可见的堆状态不再可信,但错误会沿 Rust 调用栈展开,
Drop依然执行,因此展开路径上的泄漏或引用计数失衡属于真实缺陷。
这三条原则贯穿本文全部内容,也是审查解释器代码时判断"是否构成资源限制逃逸"的根本依据。
内存上限:攻击者可影响的分配必须预先计费
check_allocation:以"实际分配的大小"计费
ResourceTracker::check_allocation(crates/monty-types/src/resource.rs)是内存预检的核心入口:
pub fn check_allocation(&self, additional: usize) -> Result<(), ResourceError> { if let Some(limit) = self.limits.max_memory { let used = probe_memory().saturating_add(additional); if used > limit { return Err(ResourceError::Memory { limit, used }); } } Ok(()) }它先读取当前已用内存probe_memory(),再加上additional(即将分配的大小),与max_memory预算比较,超限即返回ResourceError::Memory。probe_memory的实现(同文件)是全局原子计数LIVE_MEMORY减去进程基线BASELINE_MEMORY——也就是说,计费对象是从全局分配器实际请求的字节数,而非解释器堆的逻辑大小。
这里有两个必须注意的前提:
max_memory需要monty-alloc作为全局分配器:ResourceLimits::max_memory的文档明确指出,可执行程序必须安装并武装monty-alloc,否则该限制静默不生效(crates/monty-types/src/resource.rs)。ResourceTracker::new的注释也强调"否则它只是静默地不被执行"。- 计费的大小必须是实际分配的大小:不是估算值、不是"大概差不多",而是真实会发生的字节数。这条要求直接决定了下文"组合分配"的处理方式。
组合分配必须按组合后的总大小 preflight
.macroscope/correctness/resource-limits.md明确指出:两个分别被计费过的输入,其和仍可能越过max_memory。因此凡是"由多个输入拼出一个新结果"的分配,必须对结果大小做一次整体 preflight,而不能依赖每个输入在产生时已被单独计费。
源码中两个典型例证:
concat_bytes(crates/monty/src/types/bytes.rs):拼接两个bytes前,先计算result_len = lhs.len().saturating_add(rhs.len()),再通过check_repeat_size(result_len, 1, &heap.tracker)预检,然后才Vec::with_capacity(result_len);concat_allocate_str(crates/monty/src/types/str.rs):str + str的底层实现同样先check_repeat_size(result_len, 1, ...)预检结果长度,再以String::with_capacity一次性精确分配。
集合增长路径同理。list.extend/ 自扩展在 crates/monty/src/types/list.rs 中对"临时克隆 + 目标增长"按2× 组合大小一次性预检(len.saturating_mul(2 * VALUE_SIZE)),clone_all_items(同文件)则在克隆整表前预检len * VALUE_SIZE。tuple、deque、set、namedtuple 的构造路径也遵循同一模式(可在 crates/monty/src/types/deque.rs、crates/monty/src/types/tuple.rs、crates/monty/src/types/set.rs 等处看到同样的check_allocation(len.saturating_mul(VALUE_SIZE))预检)。
为什么check_time()不能充当内存检查
.macroscope/correctness/resource-limits.md特别强调:check_time()只能探测"已经分配"的内存,是事后行为,无法阻止"即将发生"的巨额分配。一次'x' * 10**12在执行检查点之前就可能耗尽机器内存,因此内存防线必须前置到分配动作本身。这也是"内存靠 preflight、时间靠轮询"两种机制分工的原因。
时间上限:check_time()与原生循环
累计执行时间:只在解释器执行时走表
ResourceTracker的时间预算不是墙钟时间,而是累计执行时间(cumulative execution time),相关语义在 docs/resource-limits.md 中有完整说明:
- 时钟只在 VM 执行字节码时走动,由
on_execution_start/on_execution_stop这一对调用划定窗口(crates/monty-types/src/resource.rs); - 执行挂起等待宿主时(外部函数调用、OS 回调)以及 REPL 两次 feed 之间,时钟暂停;
- 时间在
feed_run调用之间累计,并且被序列化进快照,恢复会话时从上次的预算继续,而非归零。
VM 的run_external(crates/monty/src/bytecode/vm/mod.rs)是这一对调用的主入口:进入执行循环前on_execution_start(),退出时on_execution_stop()。宿主还可以用set_max_duration给某个阶段(例如只给repr()结果几毫秒)重置一个更短的预算。
无限原生循环必须轮询,有界循环交给硬限制兜底
时间预算靠"执行检查点"触发,但有一类代码能绕过检查点:单个字节码指令内部的 Rust 侧循环。sum()、sorted()、min()/max()、字符串搜索、deque * n等内置操作在一次指令内跑完整个原生循环,如果不自行轮询,CPU 限制永远无法触发。crates/monty/tests/resource_limits.rs的注释明确记载了这一动机:"builtins 在一个字节码指令内运行 Rust 循环,否则会完全绕过 VM 的分发检查点",因此这些循环必须自己以分摊方式轮询 tracker(crates/monty/tests/resource_limits.rs)。
轮询不是每次迭代都读时钟,而是通过check_time_every(crates/monty-types/src/resource.rs)做分摊检查:
pub const LOOP_CHECK_INTERVAL: usize = 64; pub fn check_time_every(&self, i: usize) -> Result<(), ResourceError> { if i % Self::LOOP_CHECK_INTERVAL == Self::LOOP_CHECK_INTERVAL - 1 { self.check_time() } else { Ok(()) } }每 64 次迭代做一次完整时钟读取,短于一个区间的循环完全不付出时钟读取开销;检查点放在每个区间的末尾(i % N == N-1),保证短循环零开销、长循环有限开销。sorting.rs、deque.rs、itertools各适配器(chain、compress、dropwhile、filterfalse、islice)以及 bytes 搜索路径都使用这一模式(例如 crates/monty/src/sorting.rs、crates/monty/src/types/bytes.rs)。
与此相对,.macroscope/correctness/resource-limits.md明确禁止在有界循环中为内存目的逐迭代调用check_time()——这正是 AGENTS.md 中"不要仅为内存的原因给 Rust 循环加每迭代check_time()轮询"的规则。理由有二:其一是这类轮询是热路径噪音;其二是如果 preflight 漏掉了某个超大案例,兜底它的是分配器硬限制,而不是逐迭代轮询。
配置四个参数:语言、默认值与语义
资源限制按会话配置。docs/resource-limits.md给出了完整的参数表(docs/resource-limits.md):
| 键 | 含义 | 默认值 |
|---|---|---|
max_memory | 最大堆内存(字节) | 不限制(None) |
max_duration_secs | 最大累计执行时间(秒) | 不限制(None) |
max_recursion_depth | 最大函数调用栈深度 | 1000,不可禁用 |
gc_interval | 每 N 次 GC 跟踪分配执行一次回收 | 100,000(内置调度),不可关闭 |
Python 侧通过pydantic_monty的Monty池与pool.checkout(limits=...)配置(docs/resource-limits.md);JavaScript 侧同名驼峰字段为maxMemory、maxDurationSecs、maxRecursionDepth、gcInterval;Rust 侧则是monty_types::ResourceLimits的字段(max_duration: Option<Duration>等),定义见 crates/monty-types/src/resource.rs,并支持链式 builder:
use std::time::Duration; use monty_types::{ResourceLimits, ResourceTracker}; let limits = ResourceLimits::default() .max_duration(Duration::from_millis(20)) .max_memory(1024 * 1024) // 需要 monty-alloc 作为全局分配器 .gc_interval(50_000) .max_recursion_depth(512); let tracker = ResourceTracker::new(limits);crates/monty/README.md给出了一个完整的 Rust 运行示例:while True: pass配 20ms 预算运行后会得到包含 "time limit exceeded" 的错误(crates/monty/README.md)。fuzz 目标也演示了限制在真实进程中的用法——string_input_panic.rs与tokens_input_panic.rs均以 1 MB 内存、100ms 时长约束模糊测试输入(crates/fuzz/fuzz_targets/string_input_panic.rs)。
值得注意的两个默认值细节:
max_recursion_depth不能禁用:递归是唯一能撑爆原生栈的路径,必须始终有界,ResourceLimits::default()即只启用递归限制(crates/monty-types/src/resource.rs);RecursionError是唯一可被沙箱捕获的资源错误:ResourceError枚举中Time与Memory变体对沙箱内代码不可捕获,只有Recursion以可捕获的RecursionError呈现,对齐 CPython 行为(crates/monty-types/src/resource.rs)。错误到宿主可见异常的转换在 crates/monty/src/resource_checks.rs 中完成:内存错误映射为MemoryError、时间错误映射为TimeoutError,均不可捕获。
两级防线:软限制、硬限制与结果大小预检
软限制与硬限制的分工
max_memory是软限制:VM 在每 255 条指令的执行检查点轮询分配器用量(check_memory_time),超限则优雅地上抛MemoryError并保留会话。但检查点之间存在窗口期,一次突发分配可能在到达检查点之前就冲破限额,因此 monty-alloc 提供了硬上限兜底(crates/monty-alloc/src/lib.rs):
set_limit将"软上限 + 头寸(headroom)"发布为硬上限:常规执行BASE_HEADROOM为 4 MiB,类型检查阶段为 32 MiB(其 stub 与缓存在 Python 执行之外分配,需要更大间隙);- 全局分配器
LimitedAllocator的每次alloc/realloc/alloc_zeroed都调用charge(crates/monty-alloc/src/lib.rs):先与软上限比较,一旦同时越过硬上限立即以专用退出码终止进程(OOM_EXIT_CODE = 65,见 crates/monty-types/src/resource.rs),绝不 panic(panic 机制自身会分配内存); - 触顶后
out_of_memory先解除限额再写 stderr(写 stderr 也要分配锁),随后按exit-codefeature 选择process::exit(OOM_EXIT_CODE)或abort(),宿主据此报告MemoryError而非无法归类的SIGABRT。
软硬两级的分工结论:预检负责把"常见的、可预测的"超限变成优雅的MemoryError;硬上限负责兜住"罕见的、不可预测的"突发。这正与.macroscope/correctness/resource-limits.md"preflight 遗漏的超大案例是分配器硬限制的职责"的表述一致。
结果大小可预测的操作:分配前的数学预检
有一类操作的结果大小可以由输入推导出来,Monty 在 crates/monty/src/resource_checks.rs 中为它们实现了专门的估计函数,全部在分配发生之前执行,且只有估计超过 100 KB 阈值(LARGE_RESULT_THRESHOLD,crates/monty-types/src/resource.rs)才触发真正的 tracker 检查以控制小操作开销:
| 函数 | 覆盖操作 | 大小估计 |
|---|---|---|
check_repeat_size | 序列重复'x' * n、填充方法ljust/center/zfill | item_len * count |
check_pow_size | 整数幂base ** exp | bits(base) * exp,带4× 安全系数(反复平方法峰值期新旧 base 与新旧累加器共存) |
check_mult_size | 整数乘法 | a_bits + b_bits位 |
check_lshift_size | 左移 | value_bits + shift位 |
check_div_size | 除法溢出提升 | 被除数位数 |
check_replace_size | str.replace/bytes.replace | 扩张场景按最大匹配数估计;收缩场景按input_len |
对应实现见 crates/monty/src/resource_checks.rs。check_estimated_size的入口逻辑(同文件)保证小结果零开销,而estimate_bits_to_bytes在位数溢出时饱和到usize::MAX,确保天文数字级别的估计必然触发限额而非被静默跳过。docs 文档概括了这一行为:'x' * 10**12会立即失败,而不是先耗尽机器内存(docs/resource-limits.md)。
放大字符串必须走StringBuilder
AGENTS.md记录了一类真实事故:str.expandtabs曾因巨大tabsize把单个制表符放大为多 GB 分配。教训是:任何最终大小不受既有输入约束的字符串构建,都必须使用StringBuilder(crates/monty/src/string_builder.rs),而非String::with_capacity(...).push(...)裸拼。StringBuilder的每次 2× 扩容都在预留前对真实分配器用量预检(crates/monty/src/string_builder.rs),n 字节构建只产生O(log n)次 tracker 调用:
// 上界已知(如按宽度填充):一次 with_capacity 预检覆盖后续所有 push let mut builder = StringBuilder::with_capacity(width * fillchar.len_utf8(), &vm.heap.tracker)?; builder.push_str(s)?; for _ in 0..pad { builder.push(fillchar)?; } builder.finish(vm.heap) // 上界未知(如攻击者可控的乘数):new 之后每次扩容前预检 let mut builder = StringBuilder::new(&vm.heap.tracker); for c in input.chars() { builder.push(c)?; } builder.finish(vm.heap)StringBuilder还实现了fmt::Write,write!路径上的ResourceError会被暂存在 builder 中、由finish统一抛出(crates/monty/src/string_builder.rs)。反过来,输入本身已有界的结果(s.to_lowercase()、切片、对已跟踪字符串的to_owned())不会产生放大,直接分配即可,这也对应.macroscope/correctness/resource-limits.md中"实际大小本身已被直接 preflight 或属于小的有界结果"的免标记条款。
代码审查清单:什么必须标记,什么必须放过
.macroscope/correctness/resource-limits.md的核心产出是一套可直接操作的审查判定规则。
必须标记(资源限制逃逸,评级高)
- 到达分配器时没有对其实际大小做过
check_allocation的攻击者可影响分配(单个或组合)——典型如:未用StringBuilder构建的放大String; - 按不可信计数或组合计数预留的缓冲区或集合——即容量由用户输入推导、却只依赖输入被单独计费而没有对总大小预检的路径;
- 可以无限运行却没有
check_time()的原生循环——单个指令内的内置循环若完全依赖 VM 检查点,时间限制形同虚设。
明确不标记
- 实际大小本身已直接预检的分配,或结果小的有界分配——预检已覆盖,重复计费是噪音;
- 依赖硬限制兜底的有界原生循环——这是预期模式,不是缺陷;有界循环的逐迭代内存轮询反而被明确禁止;
- 已被正确的外部 preflight 覆盖的细粒度计费——外层已按组合大小整体预检时,内层逐项计费属于冗余。
宁缺毋滥:宁可漏报,不可误报
文档给出了一条耐人寻味的工程原则:当不确定某次计费是否冗余时,倾向沉默——"一个错过的 nit 不付出任何代价,而一次虚假的'无限分配'举报会训练团队无视这项检查"。资源限制检查作为正确性门禁,其信誉比覆盖率更重要。
限制触发后的语义:沙箱终止,Rust 侧照常清理
内存或时间限制触发后,语义分两个层面:
- 沙箱层面:上下文被丢弃。
ResourceError::Memory/Time对沙箱代码不可捕获(crates/monty-types/src/resource.rs),错误展开后不保证 Python 可见堆状态的正确性——堆中可能存在引用计数失衡的孤儿对象。docs 文档明确要求宿主在限制触发后丢弃会话,且池不会自动代做这件事(docs/resource-limits.md)。 - Rust 层面:
Drop依然运行。错误沿 Rust 调用栈展开,析构照常执行,因此展开路径上的 Rust 侧泄漏或引用计数失衡是真实缺陷——这对进程内嵌入 Monty 的宿主尤其重要,并由内存模型测试(memory-model-checksfeature,见 AGENTS.md)覆盖。.macroscope/correctness/resource-limits.md明确要求这类问题按 .macroscope/correctness/drop-discipline.md 的 drop 纪律处理,而不是以"限制已终止"为由划出范围。
唯一的例外是RecursionError:它是可捕获的,不使会话失效,执行可以继续(crates/monty-types/src/resource.rs 的check_recursion_depth在压入新调用帧前检查深度;docs/resource-limits.md 说明 1000 帧默认值、await边界计一帧、同步回调以更低的固定深度在原生栈上单独设限)。
测试与验证:从单测到子进程
资源限制的可信度来自多层测试:
- 时间限制测试:
crates/monty/tests/resource_limits.rs验证 50ms 预算下的TimeoutError与 "time limit exceeded" 消息、内置迭代循环中的超时执行、64 MB 搜索输入的近匹配边界(1ms 预算)、deque * n构建中途超时且已构建的克隆被正确释放(该文件第 356-368、594-611 行等)。一个值得注意的用例是"变更中的查找探测":探测器通过回调重入 VM 会重启分发倒计时,只有探测循环自身的check_time()能终止它(crates/monty/tests/resource_limits.rs)——这正是"无限原生循环必须自带轮询"的活教材。 - 内存测试:解释器自身测试从不武装分配器,因此
max_memory的优雅拒绝路径必须在子进程中验证——AGENTS.md 要求每个优雅路径都在large_allocations_are_rejected_before_the_hard_limit这类子进程测试中覆盖。 - 模糊测试:fuzz 目标以 1 MB / 100ms 的严格限额运行(crates/fuzz/fuzz_targets/string_input_panic.rs),用最严苛的资源配置持续冲击解析与执行路径。
结语
Monty 的资源限制正确性模型可以浓缩为一句话:凡攻击者可影响大小的分配,先按实际大小 preflight;凡可无限运行的原生循环,自带时间轮询;限制触发对沙箱终止、对 Rust 清理不终止。内存与时间分别由"分配前预检 + 分配器两级限额"与"执行检查点 + 循环分摊轮询"守护,而StringBuilder、resource_checks.rs的数学估计、monty-alloc的软硬上限构成三层纵深。这套准则既体现在 docs/resource-limits.md 的用户面配置中,也落实在ResourceTracker、VM 执行循环与每一处集合增长路径的源码里——无论是为 Monty 贡献代码、审查补丁,还是在自己的嵌入场景中配置会话限额,都可以直接以此为据。
【免费下载链接】montyA minimal, secure Python interpreter written in Rust for use by AI项目地址: https://gitcode.com/GitHub_Trending/monty3/monty
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考