Help us shape the future of Go! Take the 2026 Go Developer Survey

sandbox

package
v0.17.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 28, 2026 License: MIT Imports: 6 Imported by: 0

Documentation

Overview

Package sandbox 执行沙箱的**档位语义**与**后端协议**。

这个包解决什么

「模型写的程序在哪跑、跑起来能触达什么」这件事若只有一个写死的答案(进程内 解释器),那么「它跑在够强的隔离里吗」就无处声明——只能靠人记得别挑错后端。

本包把它变成可声明、可判定的机制:

  • Level:隔离档位(进程内解释器 → 内核级 → 硬件虚拟化),单调可比;
  • Provider:后端协议(自报提供哪一档,并执行程序);
  • Registry:登记簿,按「要求档位」选后端。

为什么「选不到就拒绝」是本包的核心语义

隔离要求若在「没有满足档位的后端」时被**降级**(拿弱后端跑),那么档位这套 机制就是装饰:宿主写下的「本次要内核级」与实际发生的「进程内跑」之间没有任何 信号,差的那个量级就是全量暴露。

故 Resolve 在无满足者时返回错误,**不静默降级**。宿主于是必须显式面对 「这个环境没有我需要的隔离档位」——要么补后端,要么把要求降到自己承担得起的 档位。两个都是有意识的动作;降级不是。

与本仓 invoke 包的关系

invoke 管的是「一次工具调用要不要放行」(判定环),本包管的是「一段被执行的 程序跑在什么隔离档位上」(执行环境)。两者是**不同的执行点**,不要混成一条链: 把本包做成 invoke 的一环,需要去猜「哪个参数字段是程序体」,那是宿主知识, 不该进原语层。

反过来,本包要接进 invoke 的判定环时,是**宿主**在装配期把两者接起来 (把「本次执行的档位要求」变成一个 Stage),本包只认协议,不认装配。

为什么档位归安全层(tier 0)而不是工具治理层

「信任边界移动了,沙箱没跟着移动」是本仓 invoke 包的立论之一——而它指出的正是 缺少一个**可声明的档位概念**:此前只有「有没有沙箱」的二元说法,没有「够不够强」 的说法,于是「隔离降级了」这类事故无法在装配期被发现,只能靠事后审计。

档位语义与判定语义同族(都是「要求 vs 实际」的对照),故与 invoke / permgate 同处安全层。

边界:档位语义归本包,「哪些执行要哪一档」归宿主

本包不认识任何领域概念,也不替宿主决定档位要求——要求是宿主按自己的风险面 声明的(那是判断,不是机制)。本包只保证一件事:**声明的档位与实际执行的后端 之间没有静默落差**。

已知局限:档位是后端自报的,本包不校验其真实性

Provider.Level() 是后端自己写的常量,本包无法机械判定「这个后端真的提供了 这么强的隔离」。因此:宿主若不信任某个后端,唯一的办法是**不登记它**。 本包不假装自己能验证隔离强度——那需要审后端实现,不是判据能做的事。

Index

Constants

This section is empty.

Variables

View Source
var ErrDuplicateProvider = errors.New("sandbox: 执行后端标识重复")

ErrDuplicateProvider 登记了重复的后端标识。

View Source
var ErrNoProvider = errors.New("sandbox: 没有已登记的执行后端达到要求的隔离档位")

ErrNoProvider 没有任何已登记的后端达到要求的隔离档位。

Functions

func SupportsAll added in v0.10.0

func SupportsAll(Budget) bool

SupportsAll 是一个 Capable:兑现全部预算项(含内存)。

只有**真能**兜住内存的后端(子进程 + 内核 rlimit 之类)才能用它。 用它之前先确认兑现是真的——档位表与支持面都是后端自报的,本包不校验其真实性。

func SupportsBasic added in v0.10.0

func SupportsBasic(b Budget) bool

SupportsBasic 是一个 Capable:只兑现基本三闸(墙钟 / 调用数 / 输出字节), **不兑现内存**。

进程内解释器后端用它——Go 运行时没有「只给一个 VM 限内存」的开关, 解释器本身也没有内存限制 API,故这类后端**如实**只能兑现三闸。

零值 Budget(无要求)返回 true:没要求内存,就不存在「兜不住内存」。

Types

type Budget

type Budget struct {
	WallClock time.Duration
	MaxCalls  int
	MaxBytes  int
	// MaxMemory 内存上限(字节)。0 = 不要求。
	//
	// 进程内后端**兜不住**这一项:Go 运行时没有「只给一个 VM 限内存」的开关,
	// 解释器本身也没有内存限制 API。要真正兜住它只能把执行搬出宿主进程。
	// 故要求它的宿主会拿到明确拒绝(该后端不被选中),而不是「以为兜住了、
	// 实际没有」。
	MaxMemory int64
}

Budget 资源预算:墙钟 / 调用次数 / 输出字节数 / 内存上限。

墙钟与调用数是所有后端都该兜住的(失控的程序会一直跑、一直调); 输出字节数是可选的(后端能截断就截断,不能就忽略)。

本包**不强制任何后端实现全部几项**——后端不支持的项忽略即可;「这项没兜住」 是后端自己的能力声明,宿主据此判断,不在这里假装统一。

「兜不住」怎么表达:Capable

后端能兜住哪些项,由它实现 Capable 自报(见 capable.go)。**不实现 Capable 的后端只兑现基本三闸、不兑现内存**——于是「要求了内存兜底而后端兜不住」 这件事有了信号:登记簿不会选中它(见 Registry.Resolve)。

type Capable added in v0.10.0

type Capable interface {
	// Supports 报告该后端能否兑现 b 这组预算。
	//
	// 返回 false 意味着:要求了 b 里的某些项,而该后端兜不住那些项。
	// 零值 Budget(全零)**恒应返回 true**——没要求任何东西就没什么兜不住。
	Supports(b Budget) bool
}

Capable 可选能力面:后端能否兑现这组预算。

为什么是**可选接口**而不是 Provider 的新方法

Provider 已在 v0.9.0 发布,给它加方法是**破坏性变更**——已实现它的宿主 全部编译不过。可选接口则让既有实现**编译期零改动**:

不实现 Capable ⇒ 只兑现基本三闸、**不兑现内存**
⇒ 要求 MaxMemory 的请求不被选中 ⇒ fail closed。

缺省语义天然落在**保守的一侧**——漏声明的后端拿到的是「兜不住」, 不是「兜得住」。方向与安全语义一致:漏声明 ⇒ 少能力,不是多豁免。

判定的是「整组」而非「逐项」

Supports(b) 一次问完「这组预算我兜不兜得住」。逐项判定(SupportsMemory 之类) 更细,但接口会随预算项增长而变大,且**逐项判定会让后端各自拼装「哪些项算 兜住了」的规则**——那份规则会漂移。整组判定把它留在后端一处(它最清楚 自己兜不兜得住),登记簿只问一次。

「不支持」与「选中后失败」不是一回事

兜不住的后端**不被选中**(见 Registry.Resolve),不是选中后执行失败。 差别在于:前者是**装配期**的判定(宿主还没跑任何东西就知道这个环境 满足不了要求),后者要等到执行时才暴露。

type CapableFunc added in v0.10.0

type CapableFunc func(b Budget) bool

CapableFunc 把一个普通函数适配成 Capable。

让后端不必为一行判定写一个具名类型:

type myProvider struct{…}

func (myProvider) Supports(b sandbox.Budget) bool {
    return b.MaxMemory <= 0 // 进程内后端兜不住内存
}

或更省事:

type myProvider struct{…}

func (myProvider) Supports(b sandbox.Budget) bool { return sandbox.SupportsBasic(b) }

func (CapableFunc) Supports added in v0.10.0

func (f CapableFunc) Supports(b Budget) bool

Supports 实现 Capable。

type Failure

type Failure struct {
	Kind    string
	Message string
}

Failure 执行失败:Kind 是后端的分类串,Message 是可读原因。

为什么不让后端直接塞一个 error:跨包通用结果需要一个**稳定**的失败形状, 否则调用方只能按类型断言逐个后端认,而每认一个就多一处会漂移的分支。 Kind 是分类(可判定),Message 是给人看的(不可判定)。

func (*Failure) Error

func (e *Failure) Error() string

Error 实现 error。

type Level

type Level uint8

Level 隔离档位。

序即强弱:Level 越大,被执行的程序能触达的东西越少。

零值是最弱的一档,这是刻意的

未声明档位的 Provider 零值即 LevelNone,于是它只会被「要求=none」选中, 而任何更高要求的执行都会因「无满足者」而**拒绝执行**。方向是安全的那一侧: 漏声明的后端拿到的是最弱的选择,不是最强的豁免。

const (
	// LevelNone 无隔离:被执行的程序与宿主同权限,能触达宿主能触达的一切。
	//
	// 它是**显式的一档**,不是「缺省即无」:把「没有隔离」写成档位,宿主就必须
	// 显式登记一个这样的后端,而不是让「没登记」悄悄等于「没隔离」。
	LevelNone Level = iota

	// LevelProcess 进程内解释器边界:程序跑在宿主进程内的解释器里,能力面靠
	// 语言子集 + 显式绑定白名单裁剪,资源面靠预算裁剪。
	//
	// 它**不是**内核级边界:这层约束由解释器自己执行,解释器自身的缺陷可以
	// 越过它。故把它标成 LevelOS 就是让档位表说谎,而档位表是宿主做风险判断的
	// 依据——说谎的成本落在宿主身上。
	LevelProcess

	// LevelOS 内核级边界:文件、网络、系统调用由内核策略裁剪。
	LevelOS

	// LevelMachine 硬件虚拟化边界:被执行的程序有自己的内核与虚拟设备。
	LevelMachine
)

func ParseLevel

func ParseLevel(s string) (Level, error)

ParseLevel 解析档位名。未知档位即报错,不退回某个缺省档位。

为什么不给缺省:档位名多半来自宿主自己的配置。配置写错时静默退回 LevelNone,等于把「我以为很强」变成「其实没有」——而这正是本包要防的那类 落差。响亮失败比安静降级好。

func (Level) AtLeast

func (l Level) AtLeast(min Level) bool

AtLeast 报告 l 是否达到(或超过)min。

func (Level) String

func (l Level) String() string

String 档位名。

type Outcome

type Outcome struct {
	// OK 是否成功。
	OK bool
	// Value 成功时的结果文本(编码由后端定)。
	Value string
	// Err 失败原因(成功时为 nil)。
	Err error
	// Detail 后端私有明细。
	//
	// 跨包的通用结果装不下后端特有的结构(出错位置、调用清单、失败分类…),
	// 而本包不认识任何后端的内部结构,故由后端自行附带、调用方按类型断言取用。
	//
	// 约定:Detail **不得**成为任何可判定事实的第二份真相——档位与成败一律以
	// 本包的字段为准,Detail 只是明细。两份真相迟早会漂移,那时没人知道该信哪份。
	Detail any
}

Outcome 一次执行的结果。

func Execute

func Execute(ctx context.Context, reg *Registry, min Level, req Request) (Outcome, error)

Execute 按要求档位选后端并执行一次程序。

这是原语侧的统一执行入口:要求档位 → 选后端 → 执行,三步在一处, 于是「这次执行实际跑在什么档位上」有唯一答案。

预算要求从 Request 里取

req.Budget 是这次执行的预算要求,它**同时用于选后端**——否则「要求了内存兜底 而后端兜不住」这件事就只在执行期暴露,而选后端这一步本可以拦下它。

既有调用方的 req.Budget.MaxMemory 是 0(新字段的零值 = 不要求内存), 故**行为逐字不变**。

type Provider

type Provider interface {
	// ID 后端标识,在登记簿里唯一。
	ID() string
	// Level 该后端提供的隔离档位。
	Level() Level
	// Exec 执行一次程序。
	Exec(ctx context.Context, req Request) (Outcome, error)
}

Provider 一个执行后端。

后端自己解释 Request

Process 读作程序源码(进程内解释器后端),外部进程后端读作可执行路径。 **本包不规定程序语言**——宿主知道自己的工具面长什么样,本包不该替它定。

谁实现 Provider

常见的一种实现是「进程内解释器」后端(隔离档位 LevelProcess);更强的后端 (内核级、硬件虚拟化)由宿主实现并注册:它们要么需要宿主自己的运行环境, 要么牵涉宿主的部署形态,都是**宿主的事**。本包只认协议,不认实现。

type Registry

type Registry struct {
	// contains filtered or unexported fields
}

Registry 后端登记簿。

并发安全:Register 与 Resolve 可并发调用。

func NewRegistry

func NewRegistry() *Registry

NewRegistry 空登记簿。

func (*Registry) Providers

func (r *Registry) Providers() []Provider

Providers 已登记的后端快照,按档位升序、同档位按标识升序。

排序是为了让「当前登记了什么」有稳定答案:Go 的 map 迭代顺序是随机的, 而宿主要把这份清单打印进审计记录或配置文件——顺序每次都变的话,那份记录 就没法拿来做两次之间的对比。

func (*Registry) Register

func (r *Registry) Register(p Provider) error

Register 登记一个后端。

重复标识即拒:两个后端同标识时,按标识选中哪一个取决于登记顺序——而 「选中哪个后端」直接决定这次执行的实际隔离强度。那是**装配期缺陷**, 不能留到执行期、靠某次恰好选对了来兜。

func (*Registry) Resolve

func (r *Registry) Resolve(min Level, budget ...Budget) (Provider, error)

Resolve 按要求档位选后端:选**最弱**满足者。

budget 是**可选**的预算要求(省略即只看档位)。给了预算时,后端还必须能兑现 那组预算(见 Capable)才会被选中。

为什么 budget 是可选参数而不是必填

Provider 与本方法都已在 v0.9.0 发布——把 budget 加成必填参数是**破坏性变更**。 可选参数让既有调用点(Resolve(min))**编译期零改动、行为逐字不变**: 省略 budget 时只看档位,与旧版一致。

为什么选最弱而不是最强

要求档位是**下限**(「至少要这么强」),更强的后端成本更高(容器启动、虚拟机 拉起都要时间)。在满足下限的前提下取最弱的,是「够用且最省」。

要求写低了、于是拿到弱后端,那是宿主自己的声明,不是门禁的失败——本包不替 宿主抬高要求,也不替它降。

没有满足者时拒绝,不降级

返回 ErrNoProvider。**绝不**退回一个弱后端继续跑——那正是本包要防的落差: 宿主声明的强度与实际执行的强度之间一旦没有信号,档位整套机制就是装饰。

兜不住预算的后端是**不被选中**,不是选中后失败

这与「档位不足」的处置同一条纪律,但**成因不同**,处置也不同:

档位不足       ⇒ 环境里有更弱的后端(降级会掉强度)⇒ 拒绝
兜不住预算     ⇒ 环境里的后端都兑现不了这项要求 ⇒ 拒绝

两者都返回 ErrNoProvider,但宿主看到错误时该做的事不一样:前者可以「把要求降一档」 (只要够用),后者只能「换执行模型」或「放弃这项要求」——降档解决不了它。

多组预算怎么合并

传多个 Budget 时**逐项取要求更高者**(MaxCalls/MaxBytes/MaxMemory 取最大, WallClock 取最长)。合并不做「求和」——那是配额语义,不是上限语义。

type Request

type Request struct {
	// Program 后端可解释的程序体(语义由后端定,见 Provider 注释)。
	Program string
	// Args 位置参数(后端可忽略)。
	Args []string
	// Dir 工作目录(后端可忽略)。
	Dir string
	// Budget 资源预算(后端按自身能力解释;不支持的项忽略)。
	Budget Budget
}

Request 一次执行请求。

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL