ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

@pydantic/monty 完全指南:在 Node.js 与浏览器中安全运行 Python 沙箱

@pydantic/monty 完全指南:在 Node.js 与浏览器中安全运行 Python 沙箱 pydantic/monty 完全指南在 Node.js 与浏览器中安全运行 Python 沙箱【免费下载链接】montyA minimal, secure Python interpreter written in Rust for use by AI项目地址: https://gitcode.com/GitHub_Trending/monty3/monty本指南围绕 monty 仓库中crates/monty-js包的官方文档crates/monty-js/README.md展开系统讲解pydantic/monty的架构、安装、会话模型、外部函数、快照暂停/恢复、文件系统挂载、资源限制与类型检查等核心能力。读完本文你将掌握如何在 Node.js 中以崩溃隔离的 worker 池运行不可信 Python 代码如何把宿主能力以函数、文件句柄与挂载目录的形式暴露给沙箱以及如何在浏览器端用 wasm 复用同一套 API。架构概览为什么沙箱必须跑在 worker 子进程里pydantic/monty是 monty 项目的 JavaScript/TypeScript 绑定。Monty 本体是一个用 Rust 编写的沙箱化 Python 解释器项目描述为 A minimal, secure Python interpreter written in Rust for use by AI。JS 绑定要解决的核心问题是沙箱进程永远无法被证明对内存错误栈溢出、分配器 abort 等完全免疫因此原生绑定pydantic/monty与pydantic/monty/node只在 worker 子进程中运行解释器某个 worker 崩溃时客户端会收到MontyCrashedError该 worker 会被池替换宿主 Node.js 进程本身永不处于风险之中这正是崩溃隔离crash-isolated的含义一次崩溃最多带走一个会话不会拖垮整个宿主。从源码看这一设计贯穿整个 crate。crates/monty-js/src/lib.rs开头即声明该 crate 是native-only浏览器中不存在子进程因此浏览器端通过精简的monty-wasm-runtime模块在 Web Worker 中运行沙箱并复用ts/worker/下的 TypeScript 池实现而不经过 napi。crates/monty-js/src/pool.rs进一步说明线程模型monty-pool是异步的每个协议回合turn都是通过Env::spawn_future_with_callback在 napi 的 tokio 运行时上生成的 future绝不在 JS 事件循环线程上执行沙箱的print()输出经线程安全函数在回合中途流出回合会等待每个 JS 回调完成从而保持打印顺序并提供背压。两种部署形态共享同一套公开 API通过包导出的browser/node条件区分Node.js池由monty解释器子进程构成平台特定的 npm 包类似 esbuild 的做法随安装自动提供原生二进制浏览器使用包的browser导出永不加载 napi loader沙箱运行在wasm32-wasip1的 Web Worker 中使用同样的池/会话 API高级 Node 专属辅助工具从pydantic/monty/node导入wasm 专属工厂从pydantic/monty/wasm导入。包的导出映射在 crates/monty-js/package.json 中有完整定义主入口.在浏览器解析到dist/worker/index.browser.jsNode 解析到dist/node.js./node与./wasm分别提供 Node 专属与 wasm 专属入口。该包要求 Node.js 20。安装npm install pydantic/monty原生绑定与monty二进制通过平台特定的 npm 包一起发布如pydantic/monty-linux-x64-gnu、pydantic/monty-darwin-arm64等安装时自动解析无需手动下载二进制。仓库中 crates/monty-js/scripts/create-platform-packages.mjs 与 assemble-packages.mjs 负责这些平台包的生成与组装。基本用法池、会话与 feedRunpydantic/monty的编程模型是池pool→ 会话session→ feed三层import { Monty } from pydantic/monty await using pool await Monty.create() await using session await pool.checkout() const result await session.feedRun(1 2) // 3Monty.create()创建池并预热minProcesses个 worker默认 1pool.checkout()从池中借出一个 worker为它创建一个专属 REPL 会话session.feedRun(code)执行一段代码并返回尾部表达式的值。一个会话是专用 worker 中的一个 REPL状态在多次 feed 之间保持await session.feedRun(x 21) await session.feedRun(x * 2) // 42如果不用await using则需要显式调用session.close()把 worker 归还池中和pool.close()。从实现上看pool.checkout()见 crates/monty-js/ts/pool.ts会把选项归一化后交给 napi 层的NativePool.checkout其ReplConfig携带scriptName默认main.py用于 traceback 与类型检查诊断、资源限制、类型检查配置与 assert 注解配置见 crates/monty-js/src/pool.rs。feedRun的驱动循环见 crates/monty-js/ts/session.ts反复向原生层发起协议回合并回答回合产生的各种挂起事件——外部函数调用、OS 回调、名字查找、异步 future——直到回合以complete收尾。输入以全局变量方式传入值feedRun的inputs选项会在代码运行前把每个条目急切地绑定为全局变量await session.feedRun(x y, { inputs: { x: 10, y: 20 } }) // 30与按需解析的externalLookup下一节不同inputs是急切绑定——无论条目是否被引用都会被写入全局作用域。原生层的convert_inputs见 crates/monty-js/src/pool.rs会逐项把 JS 值转换为线协议可承载的 Monty 值并检查嵌套深度超过MAX_VALUE_DEPTH值为 48时报Max input depth exceeded。外部查找把宿主能力按需暴露给沙箱externalLookup以惰性、按需的方式解析代码中未定义的名称。规则如下函数条目变成宿主函数沙箱可按名字调用——支持同步与异步异步函数会被 await期间其他沙箱任务继续运行其他任何值在名称被读取时直接转换并返回不存在的名称抛出NameError。await session.feedRun(add(2, 3), { externalLookup: { add: (a: number, b: number) a b }, }) // 5 await session.feedRun(await fetch_data(url), { inputs: { url: https://example.com }, externalLookup: { fetch_data: async (url: string) { const response await fetch(url) return response.text() }, }, }) await session.feedRun(greeting name, { inputs: { name: Ada }, externalLookup: { greeting: hello }, }) // hello Ada几点细节值得注意externalLookup是inputs的惰性对应物同名时由急切的inputs绑定优先服务。函数条目的关键字参数以尾随对象的形式到达宿主函数。宿主函数抛出的错误会跨入沙箱变成 Python 异常当错误的name匹配某个 Python 异常类型如TypeError时使用该类型否则退化为RuntimeError。异步外部调用的底层机制很有意思resume_future见 crates/monty-js/src/pool.rs把挂起的调用注册为外部 futureJS promise 留在 TypeScript 层其他沙箱任务继续执行直到 worker 报告全部阻塞resolveFutures时再统一交付结果。ts/session.ts中每个 feed 会新建一个应答器TurnAnswerer及其 pending-future 表避免跨 feed 累积永不回收的 promise。快照暂停与恢复执行feedStart是feedRun的可挂起版本它不再把一段代码驱动到完成而是在每次外部调用、OS 调用或名字查找处返回一个快照。用snapshot.resume(...)回答它该方法解析到下一个快照或一个MontyCompleteimport { FunctionSnapshot, MontyComplete } from pydantic/monty const snap await session.feedStart(greet(name) !, { inputs: { name: Ada } }) if (snap instanceof FunctionSnapshot) { // snap.functionName greet, snap.args [Ada] const done await snap.resume(hello Ada) if (done instanceof MontyComplete) console.log(done.output) // hello Ada! }快照类型包括FunctionSnapshot、NameLookupSnapshot、FutureSnapshot与MontyComplete见 crates/monty-js/ts/session.ts 的Snapshot联合类型。resumeAuto自动逐拍驱动如果想在不手工逐个回答挂起的前提下迭代到完成可以给feedStart传入externalLookup以及可选的os然后用snapshot.resumeAuto()驱动它会从这些选项中自动解析每个外部调用与名字查找——与feedRun的解析逻辑一致但一步一拍方便在沿途检查或dump()每个快照。返回 promise 的外部函数会被并发 await表现为中间态的FutureSnapshot与feedRun下完全相同let snap await session.feedStart(greet(name) !, { inputs: { name: Ada }, externalLookup: { greet: (n: string) hello ${n} }, }) while (!(snap instanceof MontyComplete)) { snap await snap.resumeAuto() } console.log(snap.output) // hello Ada!dump / loadSnapshot / loadSession序列化与恢复snapshot.dump()把暂停中的 worker序列化为字节新会话的loadSnapshot恢复它并返回待恢复的快照。恢复时需要重新提供原 feed 用过的挂载——宿主路径不会存进 dumpconst blob await snap.dump() // ...later, in a fresh session: const restored await session.loadSnapshot(blob) if (restored instanceof FunctionSnapshot) await restored.resume(value)session.dump()两次 feed 之间调用序列化的是空闲会话用await session.loadSession(blob)恢复解析为void后可继续 feed。loadSession与loadSnapshot只对全新会话有效任何 feed 之前把 dump 的种类用错idle dump 传给loadSnapshot或反之会抛错。loadSnapshot的一个限制是恢复后的FutureSnapshot不能resumeAuto()——其 pending promise 存在于之前的进程中需要手动resume([...])。原生层的dump/restore位于 crates/monty-js/src/pool.rs底层使用 monty 的 dump 格式整个仓库的 dump 格式定义见 crates/monty/src/dump_format.rs恢复后会话保持可用。关于暂停/恢复的更多背景可参考 docs/snapshots.md。打印输出printCallback 与宿主收集器printCallback接受一个函数或宿主收集器TypeScript 中的PrintTargetInput。输出按行缓冲不提供回调时输出到宿主进程的 stdout/stderr。// 函数形式 await session.feedRun(print(hello), { printCallback: (stream, text) console.log([${stream}] ${text}), }) // 收集器——在宿主侧累积不计入 ResourceLimits.maxMemory import { CollectString, CollectStreams, DEFAULT_MAX_PRINT_COLLECT_BYTES } from pydantic/monty const text new CollectString() await session.feedRun(print(hello), { printCallback: text }) text.output // hello\n const streams new CollectStreams() await session.feedRun(print(hello), { printCallback: streams }) streams.output // [{ stream: stdout, text: hello\n }]两个收集器默认有10 MiB上限DEFAULT_MAX_PRINT_COLLECT_BYTES即10 * 1024 * 1024。传maxBytes: null可禁用仅限可信宿主。maxBytes必须是有限非负数或null否则构造函数抛TypeError。超出上限会让该 feed 以MontyRuntimeError/MemoryError拒绝memory limit exceeded: …。从 crates/monty-js/ts/print.ts 的实现看还有两个容易被忽略的细节CollectStreams每个条目额外计收64 字节的固定开销COLLECT_STREAMS_ENTRY_OVERHEAD且不合并连续的相同流片段容量检查是先检查后追加只有当前片段会被拒绝此前成功写入的内容保留在output中缓冲区永不重置。文件系统挂载把宿主目录映射进沙箱MountDir 与三种模式MountDir把宿主目录以虚拟 POSIX 路径挂载进沙箱Node 专属 API从pydantic/monty/node导入import { MountDir } from pydantic/monty/node const mount new MountDir({ hostPath: /path/on/host, virtualPath: /mnt/data, mode: read-only }) await session.feedRun(open(/mnt/data/file.txt).read(), { mount })关键参数与行为memoryUsageLimit每个挂载默认有100 MB的聚合内存预算保留的 overlay 数据与文件系统结果共享该预算超限操作抛出包裹MemoryError的MontyRuntimeError。模式read-only、read-write与overlay默认——写入保存在内存中feed 结束时丢弃。挂载 I/O 在池的宿主侧服务因此远程 worker 也能使用挂载。构造函数会立即打开宿主目录路径不可用会在构造处抛错而不是等到第一次 feed且该挂载在其生命周期内始终跟随那个已打开的目录——之后重命名或替换该路径不会改变它。从原生层看NativeMountDir见 crates/monty-js/src/pool.rs构造时把宿主目录打开并持有其文件描述符直到对象被 drop其TryFromNativeMount负责把模式字符串映射为MountSpecMode::{ReadOnly, ReadWrite, Overlay}并校验writeBytesLimit/memoryUsageLimit 2^64的字节限制会被拒绝而不是饱和避免静默禁用预算。OS 调用优先级挂载优先os 回调兜底feedRun会自动回答每个 OS 调用挂载先获得优先权其次才是os回调。feedStart则一个都不回答——挂载覆盖的读操作也会表现为带isOsFunction标记的FunctionSnapshot只有resumeAuto()才会咨询挂载和os。未被挂载覆盖的 OS 调用到达os回调返回NOT_HANDLED表示拒绝沙箱抛出该调用默认的异常import { NOT_HANDLED } from pydantic/monty await session.feedRun(import os\nos.getenv(HOME), { os: (name, args) (name os.getenv args[0] HOME ? /home/user : NOT_HANDLED), })回调虚拟文件与 MontyFileHandle回调支撑的虚拟文件在 open 调用处返回一个MontyFileHandle标记。路径是沙箱内的虚拟 POSIX 路径position默认为 0import { MontyFileHandle, NOT_HANDLED } from pydantic/monty const files new Map([[/data/message.txt, hello from the host]]) await session.feedRun(open(/data/message.txt).read(), { os: (name, args) { const path args[0] as string if (name open) { return new MontyFileHandle(path, args[1] as string) } if (name Path.read_text) return files.get(path) ?? NOT_HANDLED return NOT_HANDLED }, })MontyFileHandle定义见 crates/monty-js/ts/types.ts是纯数据持有者绝不是活的宿主文件描述符。它会规范化mode支持r/rb/w等 Python 模式子集x独占创建模式不支持并暴露与 Python 宿主 API 相同的文件元数据path、mode、position、binary、readable、writable。传非零初始位置用new MontyFileHandle(path, mode, { position: 42 })。注意返回句柄只解决open()本身。读和写是独立的 OS 回调其第一个参数是句柄的虚拟路径——宿主永远不会收到或暴露活的文件描述符这是安全边界的关键一环。资源限制沙箱内强制 宿主侧兜底限制在 worker 内部强制实施按会话配置const limited await pool.checkout({ limits: { maxMemory: 100 * 1024 * 1024, maxDurationSecs: 5, maxRecursionDepth: 100 }, })ResourceLimits见 crates/monty-js/ts/pool.ts支持四个字段maxDurationSecs、maxMemory、gcInterval、maxRecursionDepth。省略的字段表示不限唯maxRecursionDepth例外——它有1000 帧的默认值且无法禁用。三个容易踩坑的点requestTimeout池级对卡死解释器本身的代码的兜底——worker 会被杀死会话以MontyCrashedErrortimedOut: true失败。它默认关闭推荐优先使用沙箱内的maxDurationSecs。maxDurationSecs只累计执行时间沙箱时钟只在解释器执行期间走动在等待外部函数或两次 feed 之间不走。带该限制的会话还会得到一个自动兜底worker 在每个协议回合上报累计执行时间宿主在剩余预算耗尽后durationLimitGrace默认 1s杀死它覆盖沙箱内限制无法触发的情形其检查只在解释器检查点运行。传durationLimitGrace: null可禁用。maxMemory双重强制会话的maxMemory也在 worker 自己的分配器中强制即monty-alloccrate见 crates/monty-alloc/README.md——它限制 worker 同时持有的字节数分配减去释放再加余量而不是让宿主无界增长。无法满足限制的 worker 抛出包裹MemoryError的MontyRuntimeError——但与其他运行时错误不同这个错误会带走 worker会话随之终结池会恢复。wasm worker 对分配施以同样的限制但由于 trap 的模块没有可分类的退出状态那里抛出的是MontyCrashedError。assert 消息注解失败的assert语句默认携带 pytest 风格的推理消息AssertionError: assert 2 5——这是对 CPython 空AssertionError的刻意偏离。每个操作数的 repr 默认截断到 120 字符。可按会话禁用恢复 CPython 行为或传整数自定义截断长度const session await pool.checkout({ assertMessageAnnotations: false }) const verbose await pool.checkout({ assertMessageAnnotations: 1000 })该选项的编码逻辑见 crates/monty-js/ts/options.tsundefined/true→ 缺省120 字节截断false→ 0关闭整数 → 自定义截断长度且必须是 1 到 2^32-1 之间的整数否则抛RangeError。原生层则按AssertMessageAnnotations::from_max_bytes解析见 crates/monty-js/src/pool.rs。行为背景见 limitations/assert.md。类型检查feed 前拦截类型错误开启后每个 feed 的代码在执行前先过类型检查未通过类型检查的代码不会运行会话仍然存活import { MontyTypingError } from pydantic/monty const session await pool.checkout({ typeCheck: true, typeCheckStubs: def fetch(url: str) - str: ... }) try { await session.feedRun(fetch(123)) } catch (err) { if (err instanceof MontyTypingError) { console.log(err.display()) // rendered diagnostics } }typeCheckStubs以桩文件内容形式提供给类型检查器的声明上例用...桩体定义了fetch的签名。typeCheckFormat选择诊断的渲染方式——ty 的full默认源码片段与脱字符、concise、azure、json、jsonlines、rdjson、pylint、gitlab或github完整列表见 crates/monty-js/ts/options.ts。typeCheckColor为full与concise添加 ANSI 颜色。两者都是checkout 选项而非display()参数原因是诊断在worker 内部渲染ty 的结构化诊断需要对照类型检查器的数据库解析其 span因此只有渲染后的文本跨越线路。原生层相应地在NativeCheckoutOptions中携带type_check_format与type_check_color按名称解析为TypeCheckingFormat见 crates/monty-js/src/pool.rs。const session await pool.checkout({ typeCheck: true, typeCheckFormat: json })错误处理错误类层级import { MontyError, MontySyntaxError, MontyRuntimeError, MontyCrashedError } from pydantic/monty try { await session.feedRun(1 / 0) } catch (err) { if (err instanceof MontyRuntimeError) { console.log(err.exception.typeName) // ZeroDivisionError console.log(err.display(traceback)) // full Python-style traceback } }错误类层级定义见 crates/monty-js/ts/errors.tsMontyError所有错误的基础类捕获它即捕获来自沙箱或其 worker 进程的全部失败MontySyntaxError代码无法解析时抛出内部异常总是SyntaxErrorMontyRuntimeError沙箱代码执行失败时抛出——会话存活worker 保留其全局变量后续 feed 仍可工作通过exception.typeName获取 Python 异常类型名如ZeroDivisionErrordisplay(traceback)输出完整的 Python 风格 tracebackMontyCrashedErrorworker 进程死亡会话丢失池恢复MontyTypingError类型检查失败时抛出ProtocolError协议失步等内部错误。完整的 Python traceback 在worker 内只渲染一次monty 的MontyExceptionDisplay 是唯一真相来源以字符串跨线传输结构化的栈帧filename、line、column、endLine、endColumn、functionName、sourceLine随行传递供MontyRuntimeError.traceback()程序化访问见 crates/monty-js/src/pool.rs 的frame_to_js。原生层把池级失败运行时错误、类型错误、崩溃、超时、协议失步一律解析为回合对象由 TypeScript 层从中抛出公开错误类promise 只在绑定层自身的 bug 时 reject见 crates/monty-js/src/pool.rs 的run_turn注释。池配置参数、默认值与二进制解析const pool await Monty.create({ minProcesses: 1, // 预热 worker 数 maxProcesses: 8, // 上限超出时 checkout 等待默认CPU 核数 checkoutTimeout: 10, // 等待空闲 worker 的秒数 requestTimeout: 30, // 每个协议回合的硬性截止秒 durationLimitGrace: 1, // maxDurationSecs 兜底宽限秒null 禁用 maxCheckoutsPerWorker: 100, // worker 服务这么多会话后回收 binaryPath: /path/to/monty, // 显式指定二进制默认自动解析 })各参数的语义与默认值源码见 crates/monty-js/ts/pool.ts 与 crates/monty-js/src/pool.rs参数默认值说明minProcesses1create()时预热并常驻的 worker 数maxProcessesCPU 核数availableParallelism()活跃 worker 硬上限超出时 checkout 等待校验至少为 1checkoutTimeout无限等待checkout()等不到空闲 worker 的拒绝时限秒requestTimeout关闭每回合硬性截止秒超时杀 worker会话以MontyCrashedError(timedOut: true)失败durationLimitGrace1maxDurationSecs自动兜底的宽限秒数null禁用maxCheckoutsPerWorker不限worker 服务满该数量会话后被回收换新binaryPath自动解析显式指定monty二进制路径monty 二进制的解析顺序不传binaryPath时monty二进制按以下顺序解析显式的binaryPath选项MONTY_BIN环境变量已安装的平台包如pydantic/monty-linux-x64-gnuPATHcargo workspace 的target/构建产物开发场景。该解析逻辑位于 crates/monty-js/ts/binary.ts。其他池级细节Logfire 集成Node 专属通过_installTelemetryAdapter(1, adapter)安装版本 1 适配器。checkout 时把宿主活动 trace 上下文传播进 Monty 无 exporter 的 Rust span再通过宿主 SDK 重建这些记录——凭据、导出与关闭都由宿主 SDK 负责。投递使用有界非阻塞队列溢出会永久禁用适配器并发出一条全局清理通知而不是冒宿主内存无界增长的风险。浏览器/WASM 尚未实现该适配器路径。上下文捕获见 crates/monty-js/src/pool.rs 的NativeTelemetryContextW3C trace/span ID、trace flags、trace state。worker_pidsession.workerPidnapi getter见 crates/monty-js/src/pool.rs返回当前会话 worker 的 OS 进程 ID回合进行中或未附加 worker 时为null回合线程持有 checkout 锁在事件循环上阻塞会与打印回调死锁因此用try_lock。安装第三方包原生层提供install_dependencies见 crates/monty-js/src/pool.rs通过 worker 内的uv为会话安装 Python 包使后续 feed 可导入会话作用域、可重复执行。值转换Python ↔ JavaScript 映射PythonJavaScriptNonenullboolbooleanintnumber±2^53或BigIntfloatnumberstrstringbytesBufferlistArraytuple带不可枚举__tuple__: true的ArraydictMap保留键类型与顺序set/frozensetSetdatetime 类型标记对象{ __monty_type__: DateTime, ... }文件句柄MontyFileHandledataclasses标记对象{ __monty_type__: Dataclass, ... }普通对象可作 dict 输入字符串键。标记对象__monty_type__的内部定义见 crates/monty-js/ts/types.tsMontyDate、MontyDateTime、MontyTimeDelta、MontyTimeZone、MontyException分别携带__monty_type__: Date | DateTime | TimeDelta | TimeZone | Exception标记与对应字段。公开构造函数把这些协议细节隐藏起来。线协议对类列表值的嵌套深度有上限MAX_VALUE_DEPTH为 48dict 与 dataclass 每层消耗更多递归预算因此嵌套得更浅会提前被拒见 crates/monty-js/src/pool.rs 的MAX_VALUE_DEPTH常量与sendable_resume无法跨线的返回值会变成沙箱内可捕获的错误而名字查找值无法跨线则让回合失败。kwargs 以[key, value]对数组跨线传输见 crates/monty-js/src/pool.rs 的pairs_to_js键作为普通值而非 JS 对象属性名传递——沙箱选择的__proto__之类键无法触碰任何原型。测试与验证仓库为crates/monty-js配备了完整的 Vitest 测试套件crates/monty-js/test/可运行npm testNode 原生后端、npm run test:wasmwasm 后端与npm run test:browser浏览器 Worker 后端验证上述全部行为覆盖范围包括池与会话生命周期pool.spec.ts、repl.spec.ts、public_api.spec.ts输入与外部函数inputs.spec.ts、external.spec.ts快照暂停/恢复feed_start.spec.ts资源限制与 wasm 内存限制limits.spec.ts、wasm_memory_limit.spec.ts挂载与打印mount.spec.ts、print.spec.ts类型检查与错误type_check.spec.ts、wasm_type_check.spec.ts、exceptions.spec.ts值转换types.spec.ts小结pydantic/monty把 monty 这个 Rust 编写的沙箱化 Python 解释器包装成了 Node.js 与浏览器都可用的安全执行环境worker 子进程池提供崩溃隔离会话模型提供有状态的 REPLexternalLookup / os 回调 / MountDir 三种机制按不同粒度暴露宿主能力快照机制支持跨进程暂停、恢复与迁移资源限制与类型检查在运行前和运行中双重设防。对于需要让 AI 模型或不可信代码在宿主进程之外执行 Python 的场景这是一个开箱即用的安全底座。【免费下载链接】montyA minimal, secure Python interpreter written in Rust for use by AI项目地址: https://gitcode.com/GitHub_Trending/monty3/monty创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表