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 ¶
var ErrDuplicateProvider = errors.New("sandbox: 执行后端标识重复")
ErrDuplicateProvider 登记了重复的后端标识。
var ErrNoProvider = errors.New("sandbox: 没有已登记的执行后端达到要求的隔离档位")
ErrNoProvider 没有任何已登记的后端达到要求的隔离档位。
Functions ¶
func SupportsAll ¶ added in v0.10.0
SupportsAll 是一个 Capable:兑现全部预算项(含内存)。
只有**真能**兜住内存的后端(子进程 + 内核 rlimit 之类)才能用它。 用它之前先确认兑现是真的——档位表与支持面都是后端自报的,本包不校验其真实性。
func SupportsBasic ¶ added in v0.10.0
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
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 ¶
Failure 执行失败:Kind 是后端的分类串,Message 是可读原因。
为什么不让后端直接塞一个 error:跨包通用结果需要一个**稳定**的失败形状, 否则调用方只能按类型断言逐个后端认,而每认一个就多一处会漂移的分支。 Kind 是分类(可判定),Message 是给人看的(不可判定)。
type Level ¶
type Level uint8
Level 隔离档位。
序即强弱:Level 越大,被执行的程序能触达的东西越少。
零值是最弱的一档,这是刻意的 ¶
未声明档位的 Provider 零值即 LevelNone,于是它只会被「要求=none」选中, 而任何更高要求的执行都会因「无满足者」而**拒绝执行**。方向是安全的那一侧: 漏声明的后端拿到的是最弱的选择,不是最强的豁免。
const ( // LevelNone 无隔离:被执行的程序与宿主同权限,能触达宿主能触达的一切。 // // 它是**显式的一档**,不是「缺省即无」:把「没有隔离」写成档位,宿主就必须 // 显式登记一个这样的后端,而不是让「没登记」悄悄等于「没隔离」。 LevelNone Level = iota // LevelProcess 进程内解释器边界:程序跑在宿主进程内的解释器里,能力面靠 // 语言子集 + 显式绑定白名单裁剪,资源面靠预算裁剪。 // // 它**不是**内核级边界:这层约束由解释器自己执行,解释器自身的缺陷可以 // 越过它。故把它标成 LevelOS 就是让档位表说谎,而档位表是宿主做风险判断的 // 依据——说谎的成本落在宿主身上。 LevelProcess // LevelOS 内核级边界:文件、网络、系统调用由内核策略裁剪。 LevelOS // LevelMachine 硬件虚拟化边界:被执行的程序有自己的内核与虚拟设备。 LevelMachine )
func ParseLevel ¶
ParseLevel 解析档位名。未知档位即报错,不退回某个缺省档位。
为什么不给缺省:档位名多半来自宿主自己的配置。配置写错时静默退回 LevelNone,等于把「我以为很强」变成「其实没有」——而这正是本包要防的那类 落差。响亮失败比安静降级好。
type Outcome ¶
type Outcome struct {
// OK 是否成功。
OK bool
// Value 成功时的结果文本(编码由后端定)。
Value string
// Err 失败原因(成功时为 nil)。
Err error
// Detail 后端私有明细。
//
// 跨包的通用结果装不下后端特有的结构(出错位置、调用清单、失败分类…),
// 而本包不认识任何后端的内部结构,故由后端自行附带、调用方按类型断言取用。
//
// 约定:Detail **不得**成为任何可判定事实的第二份真相——档位与成败一律以
// 本包的字段为准,Detail 只是明细。两份真相迟早会漂移,那时没人知道该信哪份。
Detail any
}
Outcome 一次执行的结果。
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 (*Registry) Providers ¶
Providers 已登记的后端快照,按档位升序、同档位按标识升序。
排序是为了让「当前登记了什么」有稳定答案:Go 的 map 迭代顺序是随机的, 而宿主要把这份清单打印进审计记录或配置文件——顺序每次都变的话,那份记录 就没法拿来做两次之间的对比。
func (*Registry) Register ¶
Register 登记一个后端。
重复标识即拒:两个后端同标识时,按标识选中哪一个取决于登记顺序——而 「选中哪个后端」直接决定这次执行的实际隔离强度。那是**装配期缺陷**, 不能留到执行期、靠某次恰好选对了来兜。
func (*Registry) Resolve ¶
Resolve 按要求档位选后端:选**最弱**满足者。
budget 是**可选**的预算要求(省略即只看档位)。给了预算时,后端还必须能兑现 那组预算(见 Capable)才会被选中。
为什么 budget 是可选参数而不是必填 ¶
Provider 与本方法都已在 v0.9.0 发布——把 budget 加成必填参数是**破坏性变更**。 可选参数让既有调用点(Resolve(min))**编译期零改动、行为逐字不变**: 省略 budget 时只看档位,与旧版一致。
为什么选最弱而不是最强 ¶
要求档位是**下限**(「至少要这么强」),更强的后端成本更高(容器启动、虚拟机 拉起都要时间)。在满足下限的前提下取最弱的,是「够用且最省」。
要求写低了、于是拿到弱后端,那是宿主自己的声明,不是门禁的失败——本包不替 宿主抬高要求,也不替它降。
没有满足者时拒绝,不降级 ¶
返回 ErrNoProvider。**绝不**退回一个弱后端继续跑——那正是本包要防的落差: 宿主声明的强度与实际执行的强度之间一旦没有信号,档位整套机制就是装饰。
兜不住预算的后端是**不被选中**,不是选中后失败 ¶
这与「档位不足」的处置同一条纪律,但**成因不同**,处置也不同:
档位不足 ⇒ 环境里有更弱的后端(降级会掉强度)⇒ 拒绝 兜不住预算 ⇒ 环境里的后端都兑现不了这项要求 ⇒ 拒绝
两者都返回 ErrNoProvider,但宿主看到错误时该做的事不一样:前者可以「把要求降一档」 (只要够用),后者只能「换执行模型」或「放弃这项要求」——降档解决不了它。
多组预算怎么合并 ¶
传多个 Budget 时**逐项取要求更高者**(MaxCalls/MaxBytes/MaxMemory 取最大, WallClock 取最长)。合并不做「求和」——那是配额语义,不是上限语义。