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 ¶
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` 的语义一致——不会因为忘了装配就放行。
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 ¶
Check 只判定,不执行(与包级 Check 同语义,但用**受管后端自己的档位要求**)。
单独暴露它是为了让调用方能**只判不跑**(先看结论再决定要不要执行), 以及让「要求的档位」可被断言——它是本包的关键不变量: 受管后端**至少**要求达到真后端自报的档位。
func (*Managed) 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 )
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 一次联合判定的输入。