gatecheck

package
v0.31.0 Latest Latest
Warning

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

Go to latest
Published: Nov 11, 2026 License: MIT Imports: 6 Imported by: 0

Documentation

Overview

Package gatecheck 审批层与沙箱层的**联合判据**。

它补的是哪一段

Codex 的安全模型是**两层**:沙箱强制(技术上能做什么)+ 审批策略 (什么时候必须问人),且**两层都同意才放行**—— 「沙箱技术上允许,但审批策略仍可要求确认」。

本仓这两层分别由两个上游模块提供,各自都很完整:

  • `guardx/sandbox`:`Registry.Resolve(min Level, budget...)`—— 档位 × 预算的联合判据,**选不到满足者即拒绝,绝不降级**;
  • `toolgate/policy`:`Gate.Decide(ctx, op)`—— 审批裁决(auto / need_human / deny / plan_only)。

但它们**互不知情**(policy 不 import sandbox,反之亦然), 于是「两层是否都同意」这个判定**没有单点**——它落在每个宿主手里。

而这条规则若由各宿主各接一遍,必然漂移:同一个调用在 A 宿主被判 「需人审」、在 B 宿主被判「自动放行」,两边都没写错, 只是各自选了不同的合成规则。这与本仓反复强调的 「判定口径必须跨宿主一致」是同一条纪律。

本包做什么、不做什么

**做**:把两层的结论合成**一条**判定,并给出**可诊断**的拒绝原因。

**不做**:不重新实现任何一层的判定(那是上游的事), 也不新增审批模式或档位(那会造成与上游并存的第二份真相)。

合成规则:任一层不通过即不通过(fail-closed)

| 审批层 | 沙箱层 | 结论 | |---|---|---| | 放行(auto) | 选得到满足要求的后端 | **放行** | | 放行(auto) | 选不到(档位不够 / 预算兜不住) | **拒绝**(档位不足) | | 需人审(need_human) | 任意 | **需人审** | | 需人审(need_human) | 选不到 | **需人审**(人审之前不必先判档位) | | 拒绝(deny) | 任意 | **拒绝**(审批已否,不必再看档位) | | 计划模式(plan_only) | 任意 | **只审不执行** |

为什么「审批需人审」优先于「档位不足」

两条拒绝对宿主的**处置完全不同**:

  • 档位不足 ⇒ 宿主该**换环境**(要求更弱的档位,或换到更强的后端);
  • 需人审 ⇒ 宿主该**去问人**。

若两者同时成立而先报「档位不足」,宿主会去找后端而不是找人—— 于是这次调用既没执行也没人管,**静默丢失**。 故「需人审」优先:**有人在场**是更强的信号。

而「审批已否」(deny)优先于一切:审批已经否了, 再去讨论档位毫无意义(档位再高也不该执行)。

计划模式(plan_only)为什么单列

计划模式是「只审不执行」——它**不是**拒绝,而是一种**不同的处置**: 宿主该去出方案给人看,而不是「重试」或「换环境」。 把它算成拒绝会让宿主以为这次调用失败了。

Index

Constants

View Source
const ManagedProviderID = "gatecheck-managed"

ManagedProviderID 受管后端的标识(**包级常量**——档位登记门禁要静态可识别)。

Variables

This section is empty.

Functions

This section is empty.

Types

type Decision

type Decision struct {
	// Outcome 结论(见上)。
	Outcome Outcome
	// Provider 选中的执行后端(仅 OutcomeProceed 时非 nil)。
	Provider sandbox.Provider
	// PolicyVerdict 审批层的原始裁决(宿主可据此展示「为什么」)。
	PolicyVerdict policy.Verdict
	// PolicyReason 审批层的理由。
	PolicyReason string
	// Level 要求的最弱档位(诊断用:宿主能直接告诉用户「要什么档位」)。
	Level sandbox.Level
	// Reason 面向人的判定理由。
	Reason string
}

Decision 联合判定的结论与依据。

func Check

func Check(ctx context.Context, gate *policy.Gate, registry *sandbox.Registry, req Request) Decision

Check 做一次联合判定。

gate 为 nil 时**按需人审处理**(fail-closed): 没装审批门就放行,等于「没人看着的调用看起来能用」—— 与 guardset「空集合拒绝构造」同一条纪律。

registry 为 nil 时沙箱层**不构成约束**(跳过档位判定): 「不要求隔离强度」与「没装沙箱」是两件事——前者是意图,后者是缺失。 但只要 MinLevel 非零(真要求了隔离强度)而 registry 缺失, 那是**要求兑现不了**⇒ 判 OutcomeLevelShort(fail-closed)。

判据顺序是有意的

1. 审批先判:审批已否 ⇒ 直接否(档位再高也不该执行); 2. 计划模式 ⇒ 只审不执行(不是失败); 3. 需人审 ⇒ 人审(人比档位更该先管); 4. 最后才判档位:只有前三条都放行,才问「档位够不够」。

func ExecuteAndRun

func ExecuteAndRun(ctx context.Context, gate *policy.Gate, registry *sandbox.Registry, req Request) (sandbox.Outcome, Decision)

ExecuteAndRun 做一次联合判定,**通过则就地执行**。

为什么不能只有 Check

`Check` 判完把后端放进 `Decision.Provider`,**但不执行**。于是调用方得自己 拿那个 provider 去 `Exec`——中间任何一步走错(拿了别的 provider、 重新 Resolve 了一次、抄错了字段),就产生「**判过的后端 ≠ 实际跑的后端**」 的落差。而「声明与实际之间没有信号」正是本包要消灭的东西: 判据一旦与执行分离,它就退化成一个可绕过的建议。

故这里把两者**焊在一起**:判定通过就用**判出来的那一个** provider 执行, 调用方没有「换一个后端跑」的机会。

与上游 sandbox.Execute 的分工

上游 `sandbox.Execute(ctx, reg, min, req)` = `Resolve` + `Exec`, **只管档位×预算**。本包管的是**审批 × 档位**的联合: 审批那一层上游没有(`toolgate/policy` 与 `guardx/sandbox` 互不知情), 而本包在**执行前**把两层都判完。

重复的部分(Resolve)是有意的:它让「判出来的 provider」与「跑起来的 provider」是**同一个**,而不是两次独立解析的结果。

审批判定在执行前,失败即不执行

审批层判 need_human / deny / plan_only 时**根本不执行**—— 尤其 deny:执行了再判「已拒绝」是没有意义的(副作用已经发生)。

type Guard

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

Guard 是「审批门 + 执行后端登记簿」的组合件:宿主装配一次,之后每次执行 走同一套联合判据。

为什么需要它(而不是让宿主每次手写 Check + ExecuteAndRun)

装配是**宿主的事**(上游 sandbox 明确「本包只认协议,不认装配」), 但**装配的形态若不固定,各宿主就会各接一遍**——于是同一条联合判据 在 A 宿主是「审批→档位→执行」,在 B 宿主是「档位→审批→执行」, 合成规则的次序就漂移了(这正是本包要消灭的东西)。

Guard 把「次序」固化在**一处**,宿主只提供零件:

g := gatecheck.NewGuard(gate, registry)
out, dec := g.Run(ctx, gatecheck.Request{...})

零值不可用

零值 Guard(门与登记簿都 nil)⇒ 审批缺失 ⇒ 按需人审(fail-closed), 与 `Check` 的语义一致——不会因为忘了装配就放行。

func NewGuard

func NewGuard(gate *policy.Gate, registry *sandbox.Registry) *Guard

NewGuard 组装组合件。gate 为 nil 时按需人审(fail-closed); registry 为 nil 时「要求了档位但兑现不了」⇒ 拒绝。

func (*Guard) Check

func (g *Guard) Check(ctx context.Context, req Request) Decision

Check 只判定,不执行(与包级 Check 同语义)。

func (*Guard) Run

func (g *Guard) Run(ctx context.Context, req Request) (sandbox.Outcome, Decision)

Run 判定并执行(与包级 ExecuteAndRun 同语义)。

type Managed

type Managed struct {
	// Inner 真后端(被管辖的那个)。
	Inner sandbox.Provider
	// Gate 审批门(nil ⇒ 需人审,fail-closed)。
	Gate *policy.Gate
	// Registry 后端登记簿(用来按要求档位选后端)。
	//
	// 注意:受管后端**自己**在登记簿里时,这里指向的是**同一个**登记簿。
	// 典型形态:宿主先注册真后端,再注册受管版;`Registry.Resolve` 选到受管版,
	// 受管版内部再 Resolve 一次(按同一档位要求选真后端)。
	//
	// 这会引入一次额外解析——但换来的是「判据与执行不可分」。
	// 若宿主不想要这层,用 `gatecheck.Check` + 自己接线(代价是判据可绕过)。
	Registry *sandbox.Registry
	// MinLevel 受管后端要求的最弱档位(缺省 = 不额外要求)。
	MinLevel sandbox.Level
	// OpFunc 从「程序 + 预算」推出审批层要判的操作。
	//
	// 缺省时用 `OpToolCall` 且**不设变更标记**(三态 nil=未知)——上游对
	// 未标记的调用 fail-safe 升人审(`Op.Mutating` 三态化后的语义:门无法
	// 从工具名判定变更性,不得默认按非变更放行)。要走出人审,宿主在映射里
	// 显式声明 `Mutating: policy.Mutating(false/true)`。
	OpFunc func(program string, budget sandbox.Budget) policy.Op
}

Managed 把一个执行后端包成「**受联合判据管辖**」的后端。

为什么需要它(接线件的形态)

宿主的真实装配是:把 `ptc.Provider`(进程内)或 `ptc.SubprocessProvider` (子进程)注册进 `sandbox.Registry`,然后 `sandbox.Execute` 选一个跑。 但那条路径**只有档位 × 预算的判定**(上游 `sandbox.Execute` = Resolve + Exec), **审批层完全不在里面**——因为 `toolgate/policy` 与 `guardx/sandbox` 互不知情。

于是宿主要么:

  • 自己在 `sandbox.Execute` 之前调一次 `gatecheck.Check`(**判据与执行分离**, 中间任何一步走错就产生「判过的后端 ≠ 实际跑的后端」的落差);要么
  • 把本包提供的 `Managed` 包在真后端外面,**注册受管版**—— 判据在 `Exec` 内部、紧挨着真正的执行,**不可绕过**。

后者才是「声明的档位与实际的执行之间没有静默落差」的完整形态。

它怎么工作

`Managed.Exec` 里:先做联合判据(审批 + 档位),通过则**委派**给真后端执行。 判据不通过 ⇒ **不委派**(副作用不会发生)。

档位语义:取真后端自报的档位

受管后端的 `Level()` 转发真后端——因为「这个后端提供多强的隔离」是 **后端的事实**,不是包装层的。包装层若自己报一个更高的档位, 就是让档位表说谎。

审批的 Op 从哪来

包装层不猜「这次调用是什么操作」——那是宿主的知识。 故 `Managed` 带一个 `OpFunc`:宿主给出「程序 + 请求 ⇒ 操作」的映射。 映射缺省时按 `OpToolCall` 处理(最保守的一档:变更类工具会被拒)。

func NewManaged

func NewManaged(inner sandbox.Provider, gate *policy.Gate, registry *sandbox.Registry, minLevel sandbox.Level) *Managed

NewManaged 组装受管后端。

func (*Managed) Check

func (m *Managed) Check(ctx context.Context, req Request) Decision

Check 只判定,不执行(与包级 Check 同语义,但用**受管后端自己的档位要求**)。

单独暴露它是为了让调用方能**只判不跑**(先看结论再决定要不要执行), 以及让「要求的档位」可被断言——它是本包的关键不变量: 受管后端**至少**要求达到真后端自报的档位。

func (*Managed) Exec

func (m *Managed) Exec(ctx context.Context, req sandbox.Request) (sandbox.Outcome, error)

Exec 联合判据 + 委派执行。

判据不通过 ⇒ **不委派**(真后端的 Exec 一次都不会被调用)。

func (*Managed) ID

func (m *Managed) ID() string

ID 后端标识(包级常量,见 ManagedProviderID)。

func (*Managed) Level

func (m *Managed) Level() sandbox.Level

Level 隔离档位:受管后端保证的**下限**。

为什么不是「转发内层后端的档位」

档位登记门禁(`harness-sandbox-provider-declared`)要求后端实现的 `ID()` 与 `Level()` 返回**可静态识别的常量**——因为「档位声明要有人签字」: 登记面(policy 里人 review 时看得见的那一行)与代码事实必须对得上, 而「内层后端是谁」是**运行期**才知道的值,签字就无从签起。

故受管后端对外声明的是**它保证的隔离强度下限**:至少进程级 (否则判据里「要求 ≥ 内层档位」不成立)。实际跑的是哪个档位的内层后端, 由 `Check` 在运行期用登记簿判——那是**判据**,不是**声明**。

这条下限是**保守**的:只承诺「不会低于进程级」,不承诺更高—— 更高档位由内层后端自己声明(`ptc.SubprocessProvider` 是进程级, 宿主自带的容器后端可以是 OS 级)。

为什么直接写 `sandbox.LevelProcess` 而不是引用一个常量

登记门禁读的是**返回表达式的字面形态**(`sandbox.LevelProcess` ⇒ "LevelProcess", 标识符 ⇒ 变量名)。写成 `return ManagedLevel` 会让门禁读到 "ManagedLevel", 与 policy 登记的 "LevelProcess" 对不上 ⇒ 报「档位漂移」。 门禁要能静态比对,代码就必须写成**可静态比对的样子**。

type Outcome

type Outcome int

Outcome 联合判定的结论。

const (
	// OutcomeProceed 两层都同意,可以执行。
	OutcomeProceed Outcome = iota
	// OutcomeNeedHuman 需要人工确认。
	OutcomeNeedHuman
	// OutcomeDenied 审批层已拒绝。
	OutcomeDenied
	// OutcomeLevelShort 沙箱层选不到满足要求的后端(档位不足 / 预算兜不住)。
	OutcomeLevelShort
	// OutcomePlanOnly 计划模式:只审不执行。
	OutcomePlanOnly
	// OutcomeExecutionFailed 两层都同意、但执行本身失败。
	//
	// 它**独立**于前面的几态:不是「不该执行」而是「执行了但没成」——
	// 宿主的处置是「看错误决定要不要重试」,与「需人审」完全不同。
	OutcomeExecutionFailed
	// OutcomeEgressShort 出口策略兑现不了(后端没声明能力 / 能力不够)。
	//
	// 与 OutcomeLevelShort 分开列:两者对宿主的处置不同——
	// 档位不足该「换更强的后端」,出口兑现不了该「换到能强制出口的后端,
	// 或把要求降下来」,混在一起会让宿主在两个错的动作里二选一。
	OutcomeEgressShort
)

func (Outcome) String

func (o Outcome) String() string

type Request

type Request struct {
	// Op 审批层要判的操作。
	Op policy.Op
	// MinLevel 要求的最弱隔离档位。
	//
	// 零值(LevelNone)表示不要求隔离强度——那沙箱层不构成约束。
	MinLevel sandbox.Level
	// Budget 资源预算要求(墙钟 / 调用数 / 输出字节 / 内存上限)。
	//
	// 零值表示不要求任何预算上限。
	Budget sandbox.Budget
	// Egress 出口策略要求(零值 = 不要求)。
	//
	// 这一维**不能省**:netguard 只覆盖本进程内走它的 HTTP 出站,
	// 子进程与宿主自起的命令行工具都在它之外。要求了出口策略而这里不判,
	// 就成了「策略写得很严、实际流量自由出网」。
	Egress egress.Spec
	// Program 要执行的程序体(语义由后端定,见 sandbox.Provider)。
	//
	// 只有 ExecuteAndRun 用它;纯判定(Check)不看它。
	Program string
}

Request 一次联合判定的输入。

Jump to

Keyboard shortcuts

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