Documentation
¶
Overview ¶
Package invoke 提供不可绕过的工具调用链:门禁链由构造保证,而不是由调用点的自觉保证。
为什么要有这个包 ¶
本仓既有的三包都只能**加**约束,没有一个能**强制**约束:
- policy.AuditGate 是装饰器——宿主忘了包一层,这次调用就没有审计门;
- toolsched 做的是调度与去重,不参与判定;
- toolprior 是优先级表,同样是数据不是闸。
「宿主忘了包」是个非常现实的失效形态:它不报错、不告警,工具照常执行, 事后从日志里也看不出这次调用没走审计门。OpenCode 2.0 上线一周内爆出的三个 安全回归(配置热重载窗口绕过 provider deny、系统级配置目录被静默丢弃、 TUI 层 sandbox 在工具执行搬进后台服务后静默失效)全部属于同一形态: **信任边界移动了,沙箱没跟着移动**,而且静默。
所以本包的立论是:把门禁从「装饰」改成「结构」。
结构在哪 ¶
NewChain 把 Handler **按值收进私有字段**,此后不再对外暴露。于是:
- 想执行一次调用,唯一的办法就是 chain.Do,它必然过完整条链;
- 宿主无法拿到裸 Handler 再绕过链去调——那个函数值已经被链吞掉了;
- 链不可变(构造后 stages 只读),并发下多个调用共享同一条链没有竞态。
这条不变量是**类型层面**的,不是约定层面的:绕过代码写不出来。
环语义只有一份 ¶
Chain.Do 之外还有一类调用方:执行体不归自己管,链用不上。典型形态是程序化编排—— 模型写的一段代码在沙箱里跑,代码里的 host.<tool>() 调宿主工具,那种执行体必须 跑在沙箱绑定闭包里(写回预算记账、追加调用账目、打断 VM),压根没法交给 Chain.Do。
这类调用方最容易长出一份「自己排的环」。而复制一份环的代价不是代码重复,是**漂移**: 环 panic 与环报错的归口环节名(stageInternal)与 Cause 都是包内未导出常量, 下游既不能引用也不能可靠复制(硬编码字符串等于与实现耦合)。漏掉的直接后果是—— 经那条路径触发的守卫 panic,被谎报成「守卫正常拒绝了」:调用方拿着错误去修策略, 而真正该修的是守卫代码。
因此求值环只有一个导出入口 EvaluateStages,Chain.Do 自己也调它:一份语义,两处受益, panic 归口只存在于本包一处。它不持有执行体(返回类型只有 error),所以不构成 绕过链的出口——绕过需要 Handler,而 Handler 只有 NewChain 内部拿得到。
与审计面的一致性 ¶
一次调用无论结局是放行、拒绝、阶段报错还是 panic,Do 都**恰好**产出一条 Record。 这条同样重要:本仓此前存在审计面不对称——经 Code Mode 的调用有完整调用账目, 普通调用没有对等物。能力更强的路径审计更好,会让人在评审时高估整体覆盖。
fail-closed ¶
构造期与执行期都偏向拒绝:
- NewChain(nil handler) 直接构造失败,不返回一个「空转」的链;
- 空工具名按拒绝处理(门禁不该对空名乐观放行);
- Stage 返回 error 时按拒绝处理,不是按「跳过该环」处理;
- Stage 或 Handler panic 时被 recover 并转成错误,不杀编排进程。
Index ¶
Constants ¶
This section is empty.
Variables ¶
var ( // ErrNoHandler 构造链时未给执行体。 ErrNoHandler = errors.New("invoke: 链必须持有执行体") // ErrEmptyName 空工具名。空名无法定位、无法审计,按拒绝而非放行。 ErrEmptyName = errors.New("invoke: 工具名为空") // ErrStageFailed 门禁环自身报错,按拒绝收口。 ErrStageFailed = errors.New("invoke: 门禁环判定失败") // ErrPanic 门禁环或执行体 panic,已被 recover 并转成错误。 ErrPanic = errors.New("invoke: 调用过程中 panic") )
哨兵错误。用 oekit/pkg/errors(与全家族一致),调用方用 errors.Is 判定。
Functions ¶
func EvaluateStages ¶
EvaluateStages 按序求值判定环:全部通过返回 nil;任一环拒绝、或环自身出错 (返回 error 或 panic),返回归口后的 *DenyError。
它存在的唯一理由是让**环语义只有一份**。Chain.Do 与任何「执行体不归自己管」的 调用方都调它,而不是各自再排一遍环。典型场景是程序化编排:模型写的一段代码在 沙箱里跑,代码里的 host.<tool>() 调宿主工具——那种执行体必须跑在沙箱绑定闭包里 (写回预算记账、追加调用账目、打断 VM),压根没法交给 Chain.Do,于是最容易 长出一份「自己排的环」。
复制一份环的代价不是代码重复,是**漂移**:环 panic 与环报错的归口环节名 (stageInternal)和 Cause 都是包内未导出常量,下游既不能引用也不能可靠复制 (硬编码字符串等于与实现耦合),一旦漏掉,「这环自己崩了」就会被谎报成 「这环正常拒绝了」——调用方于是去修策略,而真正该修的是守卫代码。
不持有执行体,因此不构成绕过链的出口:绕过需要 Handler,而 Handler 只有 NewChain 内部拿得到,Chain 也不提供取回方法。返回类型只有 error, 环节与执行体都出不来。
空工具名同样按拒绝处理,与 Chain.Do 一致:空名没有可判定的对象, 门禁不该对它乐观放行。
Types ¶
type Call ¶
type Call struct {
Name string // 工具名,必填;空名按拒绝处理
Server string // 来源标识(如 MCP server 名),单工具场景可空
Caller string // 调用者标识(agent 名 / 环节名),供审计定位
Args string // 参数原文(通常是 JSON 文本),按次静态可判
}
Call 一次工具调用的统一契约。
刻意**不**携带任何框架类型(如 policy.Op):本包与 policy/toolegate 无耦合, 审计语义由各 Stage 自己从 Name/Server/Args 读,跨包的判定口径不互相污染。
type Chain ¶
type Chain struct {
// contains filtered or unexported fields
}
Chain 不可绕过的调用链。
构造后不可变:stages 只读、handler 私有、没有取回裸 handler 的方法。 因此并发下多调用共享一条链是安全的,不需要额外同步。
func NewChain ¶
func NewChain(handler Handler, stages []Stage, opts ...ChainOption) (*Chain, error)
NewChain 构造调用链。handler 按值收进私有字段,此后不再对外暴露—— 这是「不可绕过」的全部机制:绕过代码写不出来,而不是靠文档说不要这么写。
handler 为 nil 直接构造失败:返回一个「空转的链」比构造失败危险得多, 因为它看起来能用。
type ChainOption ¶
type ChainOption func(*Chain)
ChainOption 链构造选项。
func WithRecorder ¶
func WithRecorder(f func(Record)) ChainOption
WithRecorder 挂统一调用留痕。f 为 nil 时不挂(不留痕是显式选择,不是默认)。
type DenyError ¶
type DenyError struct {
Stage string // 拒绝的环节名,"precondition" 表示链前校验拒的
Tool string
Reason string
Retryable bool
// Cause 阶段报错/panic 的原始错误。为 nil 表示这是一次正常判定得出的拒绝,
// 而不是「这一环自己出问题了」——两者对调用方的处置完全不同。
Cause error
}
DenyError 一次被门禁拒绝的调用。带是哪一环拒的、为什么、值不值得重试, 让调用方不必靠解析错误字符串做重试决策。
type Handler ¶
Handler 真正的执行体——链的终点,也是链唯一持有的执行入口。
契约:Handler 不得把 ctx 之外的可变状态当成请求无关的;跨调用共享状态由调用方 自己负责同步。本包不替 Handler 加锁(那是执行体的事,不是链的事)。
type Record ¶
type Record struct {
Call Call
Stages []StageResult
Allowed bool // 是否真的到达了执行体
Err error // 收口原因,nil 即放行成功
Duration time.Duration // 整链耗时
OutBytes int // 出参字节数(Text + Structured),留痕用
}
Record 一次调用的完整留痕。
不变式:**任何结局都恰好一条**——放行、拒绝、阶段报错、执行体报错、panic。 「有一条」和「只有一条」同样重要:多一条会让审计把同一次调用算成两次, 少一条则等于这次调用没发生过。
type Result ¶
type Result struct {
Text string
Structured json.RawMessage
}
Result 统一出参。Text 与 Structured 至多其一有值,出参类型由 Handler 自决。
type Stage ¶
Stage 链上的一环。
返回约定(两条都重要):
- (Verdict, nil):按 Verdict 判定;
- (Verdict, error):**按拒绝处理**,链立刻收口。原因为空时用错误本身兜底。
error 不被当作「这环管不着,跳过」——那会让一次故障的门禁静默变成不设防。
type StageFunc ¶
type StageFunc struct {
// StageName 该环的名字,进审计留痕;空串按 "unnamed" 记录(不留空是因为
// 「哪一环拒的」正是审计要回答的问题,空名等于这个问题没有答案)。
StageName string
Fn func(ctx context.Context, c Call) (Verdict, error)
}
StageFunc 把普通函数适配成 Stage,便于宿主快速拼一条链而不必为每环声明类型。
type StageResult ¶
StageResult 单环的判定留痕。